From c9f6b58fbf46f1912fa53c43f9ad1435cc338bf3 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Thu, 12 Nov 2026 07:28:45 +0800 Subject: [PATCH 01/11] fix(android): detect boarding from the rider's own track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boarding was often missed, and the prompt asking whether the user got on sometimes never appeared. Four independent causes: - Commit 813be81 gated the refresh loop, the location callback and the live fetch on `!appInForeground`. Because keepScreenAwakeOnRouteDetail defaults to true, the whole state machine stopped running while the route page was open — the opposite of what the setting suggests. - 雙北 boards could never be detected: the ETA feed there carries no plates, and both strong signals required one. Vehicle positions come from /realtime/buses, which does carry plates and GPS for every city. - The boarding window only existed while the user was still near the stop and expired after five minutes, after which it never reopened. - A vehicle that had already passed stayed locked, so the next bus could not be tracked. Tracking now follows the rider's own position. The decision moves to TripRideDetector, a pure Kotlin state machine with no Android dependencies, comparing the rider and each vehicle in metres along the stop sequence. A 2-D proximity test would be dominated by cross-track error: against the stop-chord line, bus fixes sit 8-16 m off at the median but 65-295 m at p90. Rides are recognised three ways, strongest first: co-movement with a live vehicle, the rider's own progress along the route, and finally asking the user — so boarding is detected even with no location at all, and the notification offers "我已上車" while waiting. Vehicle GPS trails the fetch by 22-39 s (p50), so co-movement is measured as a stable offset between the two tracks rather than proximity, and feed recency is judged by when the app received it. The ride is rebuilt when the route family changes, which is how a preliminary primary-route response is replaced once the variants arrive; a ride the user already confirmed is never dropped. Separately, 高雄 stop ids exceed 32 bits and the two sides of that join folded them differently: the session payload wrapped the low 32 bits while the live feed hashed the digits, so a 高雄 stop's live ETA could never be found, and ids that wrapped negative were dropped outright. Both now call foldStopId(), including the family fetch's own copy of that parser. Verified by 15 replay tests over real recorded feeds from TXG300, KHH100 and TPE157463: the ride is detected within 180 s while waiting at the stop and walking the same street are both rejected. The 高雄 fixture boards at a stop whose id needs folding, and the fold is checked against the ids whose low 32 bits are negative, since an id that wraps positive would agree by accident. --- android/app/src/main/AndroidManifest.xml | 4 + .../flutter/FavoriteGroupWidgetProvider.kt | 22 +- .../taiwanbus/flutter/RideRouteLine.kt | 127 ++ .../flutter/RouteTripMonitorService.kt | 1848 ++++++----------- .../flutter/SmartRouteNotificationWorker.kt | 21 +- .../avianjay/taiwanbus/flutter/StopIdCodec.kt | 45 + .../flutter/TripMonitorFamilyRealtime.kt | 10 +- .../taiwanbus/flutter/TripRideDetector.kt | 721 +++++++ .../flutter/RouteTripMonitorServiceTest.kt | 153 +- .../taiwanbus/flutter/StopIdCodecTest.kt | 85 + .../flutter/TripRideDetectorReplayTest.kt | 402 ++++ .../test/resources/ride_replay_KHH100_0.json | 1 + .../resources/ride_replay_TPE157463_0.json | 1 + .../test/resources/ride_replay_TXG300_0.json | 1 + lib/core/bus_repository.dart | 9 + 15 files changed, 2149 insertions(+), 1301 deletions(-) create mode 100644 android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt create mode 100644 android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt create mode 100644 android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodecTest.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt create mode 100644 android/app/src/test/resources/ride_replay_KHH100_0.json create mode 100644 android/app/src/test/resources/ride_replay_TPE157463_0.json create mode 100644 android/app/src/test/resources/ride_replay_TXG300_0.json diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 410e40f..996f31a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt index 1f16c4f..dd245c5 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt @@ -449,7 +449,9 @@ object FavoriteGroupWidgetSupport { val item = groupArray.optJSONObject(index) ?: continue val type = item.optString("type", "boarding").trim().ifEmpty { "boarding" } val routeKey = item.optInt("routeKey", 0) - val stopId = item.optInt("stopId", 0) + // The config is written by Dart with the raw feed id, which can + // exceed Int for 高雄; fold it the same way the live feed does. + val stopId = foldStopId(item.opt("stopId")) val routeId = item.optString("routeId", "").trim().takeIf { it.isNotEmpty() } val stationId = item.optString("stationId", "").trim() val valid = when (type) { @@ -461,7 +463,7 @@ object FavoriteGroupWidgetSupport { if (!valid) { continue } - val destinationStopId = item.optInt("destinationStopId", 0) + val destinationStopId = foldStopId(item.opt("destinationStopId")) .takeIf { it > 0 } val destinationPathId = if (destinationStopId == null) { null @@ -690,21 +692,7 @@ object FavoriteGroupWidgetSupport { return result } - private fun parseStopId(raw: Any?): Int { - if (raw is Number) { - return raw.toInt() - } - val text = raw?.toString()?.trim().orEmpty() - val parsed = text.toIntOrNull() - if (parsed != null) { - return parsed - } - var hash = 17 - for (char in text) { - hash = (hash * 31 + char.code) and 0x7fffffff - } - return hash - } + private fun parseStopId(raw: Any?): Int = foldStopId(raw) private fun toIntOrNull(raw: Any?): Int? { return when (raw) { diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt new file mode 100644 index 0000000..06ad82b --- /dev/null +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideRouteLine.kt @@ -0,0 +1,127 @@ +package tw.avianjay.taiwanbus.flutter + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.hypot + +internal data class RideProjection( + val alongMeters: Double, + val crossTrackMeters: Double, + val segmentIndex: Int, +) + +/** + * The stop sequence as a polyline in a local equirectangular plane, with + * cumulative distance, so that the user and every bus reduce to one comparable + * number: metres travelled from the first stop. + */ +internal class RideRouteLine(private val stops: List) { + private val originLat = stops.first().lat + private val originLon = stops.first().lon + private val metersPerDegreeLon = EARTH_RADIUS_METERS * cos(originLat * PI / 180.0) * PI / 180.0 + private val metersPerDegreeLat = EARTH_RADIUS_METERS * PI / 180.0 + private val xs = DoubleArray(stops.size) { (stops[it].lon - originLon) * metersPerDegreeLon } + private val ys = DoubleArray(stops.size) { (stops[it].lat - originLat) * metersPerDegreeLat } + private val cumulativeMeters = DoubleArray(stops.size).also { cumulative -> + for (index in 1 until stops.size) { + cumulative[index] = cumulative[index - 1] + + hypot(xs[index] - xs[index - 1], ys[index] - ys[index - 1]) + } + } + + /** Metres from the first stop to [stopIndex]. */ + fun alongOfStop(stopIndex: Int): Double = cumulativeMeters[stopIndex.coerceIn(0, stops.size - 1)] + + fun segmentLengthMeters(segmentIndex: Int): Double { + val index = segmentIndex.coerceIn(0, (stops.size - 2).coerceAtLeast(0)) + return if (stops.size < 2) 0.0 else cumulativeMeters[index + 1] - cumulativeMeters[index] + } + + fun metersBetween(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + return hypot((lon2 - lon1) * metersPerDegreeLon, (lat2 - lat1) * metersPerDegreeLat) + } + + fun metersToStop(latitude: Double, longitude: Double, stopIndex: Int): Double { + val stop = stops[stopIndex.coerceIn(0, stops.size - 1)] + return metersBetween(latitude, longitude, stop.lat, stop.lon) + } + + /** + * Projects a fix onto the polyline. + * + * [hintMeters] disambiguates routes that run along the same street twice + * (loops, out-and-back sections): among the segments about as close as the + * closest one, the one nearest the previous along-route position wins. + */ + fun project(latitude: Double, longitude: Double, hintMeters: Double?): RideProjection { + val segmentCount = stops.size - 1 + if (segmentCount <= 0) { + return RideProjection(0.0, metersToStop(latitude, longitude, 0), 0) + } + val px = (longitude - originLon) * metersPerDegreeLon + val py = (latitude - originLat) * metersPerDegreeLat + val distances = DoubleArray(segmentCount) + val alongs = DoubleArray(segmentCount) + var closest = 0 + for (index in 0 until segmentCount) { + val vx = xs[index + 1] - xs[index] + val vy = ys[index + 1] - ys[index] + val lengthSquared = vx * vx + vy * vy + val t = if (lengthSquared <= 1e-9) { + 0.0 + } else { + (((px - xs[index]) * vx + (py - ys[index]) * vy) / lengthSquared).coerceIn(0.0, 1.0) + } + distances[index] = hypot(px - (xs[index] + t * vx), py - (ys[index] + t * vy)) + alongs[index] = cumulativeMeters[index] + + t * (cumulativeMeters[index + 1] - cumulativeMeters[index]) + if (distances[index] < distances[closest]) { + closest = index + } + } + var chosen = closest + if (hintMeters != null) { + for (index in 0 until segmentCount) { + if (distances[index] <= distances[closest] + HINT_SLACK_METERS && + abs(alongs[index] - hintMeters) < abs(alongs[chosen] - hintMeters) + ) { + chosen = index + } + } + } + return RideProjection(alongs[chosen], distances[closest], chosen) + } + + fun nearestStopIndex(alongMeters: Double): Int { + var best = 0 + for (index in cumulativeMeters.indices) { + if (abs(cumulativeMeters[index] - alongMeters) < abs(cumulativeMeters[best] - alongMeters)) { + best = index + } + } + return best + } + + /** + * The last stop reached at [alongMeters]. A vehicle pulling in counts as + * having reached the stop, hence the tolerance. Unlike the nearest stop, + * this only moves forward as the vehicle does, so "stops to go" counts down + * at the stops themselves rather than midway between them. + */ + fun stopIndexAt(alongMeters: Double): Int { + var reached = 0 + for (index in cumulativeMeters.indices) { + if (cumulativeMeters[index] <= alongMeters + STOP_REACHED_TOLERANCE_METERS) { + reached = index + } + } + return reached + } + + private companion object { + const val EARTH_RADIUS_METERS = 6_371_000.0 + const val HINT_SLACK_METERS = 60.0 + const val STOP_REACHED_TOLERANCE_METERS = 25.0 + } +} diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index 2cad1d1..d820846 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -15,6 +15,7 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.PowerManager import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -27,6 +28,7 @@ import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import java.net.HttpURLConnection +import java.net.URLEncoder import java.net.URL import java.util.concurrent.Executors import kotlin.math.abs @@ -49,26 +51,18 @@ class RouteTripMonitorService : Service() { private var appInForeground = true private var foregroundStarted = false private var latestLocation: Location? = null - private var lastMovementLocation: Location? = null - private var lastMovementNearestIndex: Int? = null - private var lastMovementRecordedAtMs = 0L - private var lastTransitLikeAtMs = 0L + + /** + * Owns the "is the user on the bus" question. It is fed the user's own fixes + * and the live vehicle positions, and never looks at whether the app is in + * the foreground — a trip does not stop being a trip when the user opens + * the app. + */ + private var detector: TripRideDetector? = null + private var latestRide = RideSnapshot() + private var detectorLock = Any() private var boardingAlertSent = false - private var boardingCheckPromptSent = false - private var boardingCheckPromptSentAtMs = 0L - private var boardingWindowOpen = false - private var boardingWindowOpenedAtMs = 0L - private var boardingCheckSnoozeUntilMs = 0L - private var rideConfirmed = false - private var rideConfirmationSamples = 0 - private var lastNearestStopIndex: Int? = null - private var lastBusStopIndex: Int? = null - private var activeBoardingVehicleId: String? = null - private var activeBoardingVehicleStopIndex: Int? = null - private var activeBoardingVehicleLastSeenAtMs = 0L - private var activeBoardingVehiclePassedAtMs = 0L - private var trackedBusId: String? = null - private var trackedBusLastSeenAtMs = 0L + private var boardingCheckNotificationId = 0L private var destinationSetupPromptSent = false private var arrivalDetectedAtMs = 0L private var destinationAlertStage = 0 @@ -81,26 +75,49 @@ class RouteTripMonitorService : Service() { private var cachedLivePathId: Int? = null private var cachedLiveFetchedAtMs = 0L private var cachedLiveStops: Map = emptyMap() + private var cachedBusesRouteId: String? = null + private var cachedBusesFetchedAtMs = 0L + private var cachedBusReports: List = emptyList() + private var liveFetchBackoffUntilMs = 0L + private var wakeLock: PowerManager.WakeLock? = null private val pollingRunnable = object : Runnable { override fun run() { if (!foregroundStarted) { return } - if (!appInForeground) { - refreshNotification() - } + // Refresh in the foreground too: the notification, the ETA text and + // the boarding question all depend on this loop, and skipping it + // while the app is open was why boarding went unnoticed. + refreshNotification() mainHandler.postDelayed(this, POLL_INTERVAL_MS) } } private val locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { - latestLocation = locationResult.lastLocation - if (!appInForeground) { - refreshNotification() - } - } + val location = locationResult.lastLocation ?: return + latestLocation = location + onUserLocation(location) + refreshNotification() + } + } + + private fun onUserLocation(location: Location): Unit = synchronized(detectorLock) { + val currentDetector = detector ?: return + currentDetector.onUserLocation( + RideGeoPoint( + latitude = location.latitude, + longitude = location.longitude, + // Fixes can be minutes old when the provider hands back a + // cached position; ageing them here keeps the whole detector on + // the wall clock the rest of the service uses. + timestampMs = System.currentTimeMillis() - + (SystemClock.elapsedRealtime() - location.time).coerceAtLeast(0L), + accuracyMeters = if (location.hasAccuracy()) location.accuracy else 0f, + speedMetersPerSecond = location.speed.takeIf { location.hasSpeed() && it > 0f }, + ), + ) } override fun onCreate() { @@ -157,11 +174,7 @@ class RouteTripMonitorService : Service() { return START_NOT_STICKY } refreshNotification(force = true) - if (appInForeground) { - stopPolling() - } else { - startPolling() - } + startPolling() return START_STICKY } @@ -180,17 +193,13 @@ class RouteTripMonitorService : Service() { stopTracking() return START_NOT_STICKY } - rideConfirmed = true - rideConfirmationSamples = REQUIRED_RIDE_CONFIRMATION_SAMPLES - boardingWindowOpen = true - boardingCheckPromptSent = true - boardingCheckPromptSentAtMs = System.currentTimeMillis() - normalizeVehicleId(activeBoardingVehicleId)?.let { vehicleId -> - trackedBusId = vehicleId - trackedBusLastSeenAtMs = System.currentTimeMillis() + val now = System.currentTimeMillis() + synchronized(detectorLock) { + detector?.confirmBoardedByUser(now) + latestRide = detector?.snapshot(now) ?: RideSnapshot() } - activeBoardingVehiclePassedAtMs = 0L - notificationManager.cancel(ALERT_NOTIFICATION_ID) + boardingAlertSent = true + cancelBoardingCheckPrompt() refreshNotification(force = true) return START_STICKY } @@ -200,26 +209,16 @@ class RouteTripMonitorService : Service() { stopTracking() return START_NOT_STICKY } + synchronized(detectorLock) { + detector?.declineBoarding() + latestRide = detector?.snapshot(System.currentTimeMillis()) ?: RideSnapshot() + } boardingAlertSent = false - rideConfirmed = false - rideConfirmationSamples = 0 - boardingCheckPromptSent = false - boardingCheckPromptSentAtMs = 0L - boardingWindowOpen = false - boardingWindowOpenedAtMs = 0L - trackedBusId = null - trackedBusLastSeenAtMs = 0L - activeBoardingVehicleId = null - activeBoardingVehicleStopIndex = null - activeBoardingVehicleLastSeenAtMs = 0L - activeBoardingVehiclePassedAtMs = 0L - lastMovementLocation = null - lastMovementNearestIndex = null - lastMovementRecordedAtMs = 0L - lastTransitLikeAtMs = 0L - boardingCheckSnoozeUntilMs = - System.currentTimeMillis() + BOARDING_CHECK_SNOOZE_MS - notificationManager.cancel(ALERT_NOTIFICATION_ID) + destinationSetupPromptSent = false + arrivalDetectedAtMs = 0L + destinationAlertStage = 0 + overshootAlertSent = false + cancelBoardingCheckPrompt() refreshNotification(force = true) return START_STICKY } @@ -235,7 +234,10 @@ class RouteTripMonitorService : Service() { val previousDestination = session?.destinationStopId val previousBoarding = session?.boardingStopId val previousRouteId = session?.routeId + val previousRouteKey = session?.routeKey val previousFamily = session?.realtimeRouteIds?.toSet() + val hadSession = session != null + val hadDetector = session != null && detectorExists() if (AppRuntimeStateStore.isTripMonitorPausedFor(this, parsedSession)) { session = parsedSession appInForeground = parsedSession.appInForeground @@ -261,61 +263,68 @@ class RouteTripMonitorService : Service() { AppRuntimeStateStore.setAppInForeground(this, appInForeground) val tripOriginChanged = previousBoarding != parsedSession.boardingStopId || - previousRouteId != parsedSession.routeId + previousRouteId != parsedSession.routeId || + // Some providers leave routeId blank, so the route key + // is what actually tells two routes apart. + previousRouteKey != parsedSession.routeKey val destinationChanged = previousDestination != parsedSession.destinationStopId val familyChanged = previousFamily != parsedSession.realtimeRouteIds.toSet() // A preliminary primary-route response must not keep a waiting - // reminder locked to that bus once the family arrives. - if (tripOriginChanged || (familyChanged && !rideConfirmed)) { + // reminder locked to that bus once the family arrives, and the + // detector must exist before anything can consult it. A ride the + // user already confirmed is never rebuilt out from under them. + val rideAlreadyConfirmed = currentRideSnapshot().hasBoarded + if ( + tripOriginChanged || + (familyChanged && !rideAlreadyConfirmed) || + !hadDetector + ) { boardingAlertSent = false - boardingCheckPromptSent = false - boardingCheckPromptSentAtMs = 0L - boardingWindowOpen = false - boardingWindowOpenedAtMs = 0L - boardingCheckSnoozeUntilMs = 0L - rideConfirmed = false - rideConfirmationSamples = 0 - lastNearestStopIndex = null - lastBusStopIndex = null - activeBoardingVehicleId = null - activeBoardingVehicleStopIndex = null - activeBoardingVehicleLastSeenAtMs = 0L - activeBoardingVehiclePassedAtMs = 0L - lastMovementLocation = null - lastMovementNearestIndex = null - lastMovementRecordedAtMs = 0L - lastTransitLikeAtMs = 0L - trackedBusId = null - trackedBusLastSeenAtMs = 0L destinationSetupPromptSent = false arrivalDetectedAtMs = 0L destinationAlertStage = 0 overshootAlertSent = false - } else if (destinationChanged) { + cancelBoardingCheckPrompt() + synchronized(detectorLock) { + detector = createDetector(parsedSession) + latestRide = RideSnapshot() + } + refreshRideSnapshot(System.currentTimeMillis()) + // Only the very first update seeds the detector from the + // session's stale position; a later re-seed would replay an + // old fix as if it were current. + if (!hadSession) { + latestLocation = createSessionLocation(parsedSession) ?: latestLocation + latestLocation?.let(::onUserLocation) + } + } else { // Choosing a destination after boarding must not unlock the // current vehicle and fall back to the next bus at the // boarding stop. Only the destination alert lifecycle is new. - destinationSetupPromptSent = false - arrivalDetectedAtMs = 0L - destinationAlertStage = 0 - overshootAlertSent = false + if (destinationChanged) { + destinationSetupPromptSent = false + arrivalDetectedAtMs = 0L + destinationAlertStage = 0 + overshootAlertSent = false + } + synchronized(detectorLock) { + detector?.updateDestination(parsedSession.destinationStopId) + } } - latestLocation = createSessionLocation(parsedSession) ?: latestLocation createAlertNotificationChannel() requestLocationUpdates() + acquireWakeLock() refreshNotification(force = true) - if (appInForeground) { - stopPolling() - } else { - startPolling() - } + startPolling() } } return START_STICKY } + private fun detectorExists(): Boolean = synchronized(detectorLock) { detector != null } + override fun onDestroy() { mainHandler.removeCallbacksAndMessages(null) runCatching { @@ -323,10 +332,43 @@ class RouteTripMonitorService : Service() { fusedLocationClient.removeLocationUpdates(locationCallback) } } + releaseWakeLock() ioExecutor.shutdownNow() super.onDestroy() } + /** + * A partial wake lock for as long as a trip is being tracked. + * + * With the screen off and the phone still, the CPU suspends between wakeups + * and the 15-second refresh loop stalls — the app cannot then tell "still + * waiting at the stop" from "already on the bus". Holding this keeps the + * loop and the detector's timers running for the length of the trip. + * + * This covers the idle/screen-off case. Deep Doze ignores wake locks + * outright, so a phone that has been motionless for half an hour is still + * at the mercy of the platform and the OEM; the lock is a mitigation, not + * a guarantee. + */ + private fun acquireWakeLock() { + val manager = getSystemService(PowerManager::class.java) ?: return + if (wakeLock?.isHeld == true) { + return + } + val lock = runCatching { + manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG) + }.getOrNull() ?: return + lock.setReferenceCounted(false) + runCatching { lock.acquire(WAKE_LOCK_TIMEOUT_MS) } + wakeLock = lock + } + + private fun releaseWakeLock() { + val lock = wakeLock ?: return + wakeLock = null + runCatching { if (lock.isHeld) lock.release() } + } + private fun buildStartupSnapshot(session: TrackingSession): TrackingSnapshot { val startupLocation = latestLocation ?: createSessionLocation(session) val nearestIndex = startupLocation?.let { location -> @@ -372,6 +414,15 @@ class RouteTripMonitorService : Service() { } } + private fun createDetector(session: TrackingSession): TripRideDetector { + return TripRideDetector( + stops = session.stops, + pathId = session.pathId, + boardingStopId = session.boardingStopId, + destinationStopId = session.destinationStopId, + ) + } + private fun ensureForegroundStarted(): Boolean { if (foregroundStarted) { return true @@ -417,7 +468,13 @@ class RouteTripMonitorService : Service() { } private fun requestLocationUpdates() { - if (session?.backgroundLocationAlwaysGranted != true) { + // Detection needs the user's own track, so updates are requested + // whenever any location permission is held. Requiring the "allow all + // the time" grant here is what used to leave the whole boarding state + // machine inert: a location-type foreground service started while the + // app was visible keeps while-in-use access after it goes to the + // background, so the stricter grant is not needed to keep tracking. + if (!hasLocationPermission()) { return } if (!::fusedLocationClient.isInitialized) { @@ -426,8 +483,12 @@ class RouteTripMonitorService : Service() { val request = LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, LOCATION_UPDATE_INTERVAL_MS, - ).setMinUpdateDistanceMeters(20f) - .setMinUpdateIntervalMillis(LOCATION_MIN_UPDATE_INTERVAL_MS) + ).setMinUpdateIntervalMillis(LOCATION_MIN_UPDATE_INTERVAL_MS) + // No distance filter: a phone waiting at the stop must keep + // reporting, both so the app can tell the user is still there and + // so a fix is ready the moment they board. + .setMinUpdateDistanceMeters(0f) + .setWaitForAccurateLocation(false) .build() runCatching { @@ -441,14 +502,24 @@ class RouteTripMonitorService : Service() { fusedLocationClient.lastLocation.addOnSuccessListener { location -> if (location != null) { latestLocation = location - if (!appInForeground) { - refreshNotification() - } + onUserLocation(location) + refreshNotification() } } } } + private fun hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.ACCESS_COARSE_LOCATION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + private fun startPolling() { mainHandler.removeCallbacks(pollingRunnable) if (!foregroundStarted) { @@ -492,6 +563,13 @@ class RouteTripMonitorService : Service() { buildTrackingNotification(buildStartupSnapshot(currentSession)), ) } + // Fetched before the snapshot: everything the detector + // learns arrives through here. + updateLiveData(currentSession) + // Advanced on every refresh, not just when the bus feed + // came back, because the question timing and the user's + // own progress move independently of it. + refreshRideSnapshot(System.currentTimeMillis()) val trackingSnapshot = buildSnapshot( currentSession, latestLocation ?: createSessionLocation(currentSession), @@ -530,306 +608,272 @@ class RouteTripMonitorService : Service() { } } - private fun buildSnapshot( + /** + * Pulls whatever the detector needs from the backend: the boarding stop's + * ETA, and the live positions of every vehicle on the route. + * + * Both feeds share one rate-limit bucket with the rest of the app, so they + * are throttled by age rather than fetched on every refresh, and a rejection + * from the server backs everything off instead of hammering it. + */ + private fun updateLiveData(session: TrackingSession) { + val routeIds = session.realtimeRouteIds + if (routeIds.isEmpty()) { + return + } + val nowElapsed = SystemClock.elapsedRealtime() + if (nowElapsed < liveFetchBackoffUntilMs) { + return + } + + val wantsStops = cachedLiveRouteIds != routeIds || + cachedLivePathId != session.pathId || + nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS + if (wantsStops) { + val liveStops = fetchLiveStopMapFromBackend(session) + // familyRealtime folds a failed request into an empty map, and a + // route with stops to look up that answers with nothing is a + // failure rather than an empty feed — so back off rather than + // spend the shared rate limit on retries. + if (liveStops.isEmpty()) { + liveFetchBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS + return + } + synchronized(refreshLock) { + cachedLiveRouteIds = routeIds + cachedLivePathId = session.pathId + cachedLiveFetchedAtMs = nowElapsed + cachedLiveStops = liveStops + } + feedBoardingEta(session, liveStops) + } + + val primaryRouteId = session.routeId?.trim().orEmpty() + if (primaryRouteId.isEmpty()) { + return + } + val wantsBuses = cachedBusesRouteId != primaryRouteId || + nowElapsed - cachedBusesFetchedAtMs >= LIVE_BUS_REFRESH_INTERVAL_MS + if (wantsBuses) { + val reports = fetchBusReportsFromBackend(primaryRouteId) ?: return + synchronized(refreshLock) { + cachedBusesRouteId = primaryRouteId + cachedBusesFetchedAtMs = nowElapsed + cachedBusReports = reports + } + synchronized(detectorLock) { + detector?.onBusReports(reports) + } + } + } + + private fun feedBoardingEta( session: TrackingSession, - location: Location?, - ): TrackingSnapshot { - val liveStops = fetchLiveStopMap(session) - if (this.session !== session) return buildStartupSnapshot(session) - if (location == null) { - return TrackingSnapshot( - title = session.routeName, - content = "等待目前位置...", - subText = session.pathName.ifBlank { "背景乘車提醒進行中" }, - progressMax = null, - progressValue = null, - shortCriticalText = "定位中", + liveStops: Map, + ) { + val boardingStopId = session.boardingStopId ?: return + val liveStop = liveStops[boardingStopId] ?: return + val etaEntry = liveStop.etaEntries.firstOrNull() + val arriving = liveStop.seconds?.let { it <= 0 } == true || + liveStop.vehicleIds.isNotEmpty() || + liveStop.message?.contains("進站") == true || + liveStop.message?.contains("到站") == true + val now = System.currentTimeMillis() + synchronized(detectorLock) { + detector?.onBoardingStopEta( + etaSeconds = liveStop.seconds ?: etaEntry?.seconds, + arriving = arriving, + nowMs = now, ) } + } - val nearestIndex = findNearestStopIndex(session.stops, location) - if (nearestIndex == -1) { - return TrackingSnapshot( - title = session.routeName, - content = "暫時無法判斷最近站牌", - subText = session.pathName.ifBlank { "背景乘車提醒進行中" }, - progressMax = null, - progressValue = null, - shortCriticalText = "更新中", - ) + private fun refreshRideSnapshot(nowMs: Long) { + synchronized(detectorLock) { + latestRide = detector?.snapshot(nowMs) ?: RideSnapshot() } + } - val nearestStop = session.stops[nearestIndex] - val nearestStopDistanceMeters = distanceMeters( - location.latitude, - location.longitude, - nearestStop.lat, - nearestStop.lon, - ) - val nearestLiveStop = liveStops[nearestStop.stopId] + private fun currentRideSnapshot(): RideSnapshot = synchronized(detectorLock) { latestRide } + + /** + * Builds the notification from the detector's view of the trip plus the + * live stop feed. The detector owns every "where is the user in the trip" + * decision; this only renders it. + */ + private fun buildSnapshot( + session: TrackingSession, + location: Location?, + ): TrackingSnapshot { + val liveStops = fetchLiveStopMap(session) + if (this.session !== session) return buildStartupSnapshot(session) + // A missing fix is not an early exit: the detector can still raise the + // boarding question without any location at all, and the notification + // is built from what it knows rather than from a nearest-stop guess. + val ride = currentRideSnapshot() + val nearestIndex = location?.let { findNearestStopIndex(session.stops, it) } ?: -1 + val nearestStop = session.stops.getOrNull(nearestIndex) + val nearestLiveStop = nearestStop?.let { liveStops[it.stopId] } val nearestEtaText = displayEtaText(nearestLiveStop) - val busIndex = findClosestBusIndex(session.stops, liveStops, nearestIndex) - val destinationIndex = session.destinationStopId?.let { destinationStopId -> - session.stops.indexOfFirst { stop -> stop.stopId == destinationStopId } - .takeIf { it >= 0 } - } - - if (destinationIndex == null && session.boardingStopId != null) { - val boardingIndex = session.stops.indexOfFirst { stop -> - stop.stopId == session.boardingStopId - }.takeIf { it >= 0 } ?: nearestIndex - val boardingStop = session.stops[boardingIndex] - val boardingLiveStop = liveStops[boardingStop.stopId] - val boardingVehicleCandidate = resolveBoardingVehicleCandidate( - stops = session.stops, - liveStops = liveStops, - location = location, - nearestIndex = nearestIndex, - boardingIndex = boardingIndex, - ) - val boardingVehicleId = boardingVehicleCandidate?.vehicleId ?: activeBoardingVehicleId - val boardingEtaText = displayEtaText(boardingLiveStop, boardingVehicleId) - val boardingEtaSeconds = resolveEtaSeconds(boardingLiveStop, boardingVehicleId) - val boardingDistanceMeters = distanceMeters( - location.latitude, - location.longitude, - boardingStop.lat, - boardingStop.lon, - ) - val fallbackBoardingBusIndex = findClosestBusIndexForBoarding( - stops = session.stops, - liveStops = liveStops, - boardingIndex = boardingIndex, - ) - val boardingBusIndex = - boardingVehicleCandidate?.stopIndex ?: activeBoardingVehicleStopIndex ?: fallbackBoardingBusIndex - val busStopsUntilBoarding = boardingBusIndex?.let { - (boardingIndex - it).coerceAtLeast(0) - } - val rideState = if (session.backgroundLocationAlwaysGranted) { - updateRideState( - location = location, - nearestIndex = nearestIndex, - boardingIndex = boardingIndex, - boardingBusIndex = boardingBusIndex, - boardingVehicleId = boardingVehicleId, - boardingEtaText = boardingEtaText, - boardingEtaSeconds = boardingEtaSeconds, - boardingDistanceMeters = boardingDistanceMeters, - ) - } else { - resolveNoLocationRideState( - busStopsUntilBoarding = busStopsUntilBoarding, - boardingEtaText = boardingEtaText, - boardingEtaSeconds = boardingEtaSeconds, - ) - } - val userBusLiveStop = busIndex?.let { liveStops[session.stops[it].stopId] } - trackedBusId = if (rideState.hasBoarded) { - selectTrackedBusId( + val nearestStopDistanceMeters = if (location != null && nearestStop != null) { + distanceMeters(location.latitude, location.longitude, nearestStop.lat, nearestStop.lon) + } else { + null + } + val destinationIndex = session.destinationStopId + ?.let { id -> session.stops.indexOfFirst { it.stopId == id } } + ?.takeIf { it >= 0 } + ?: -1 + val destinationStop = session.stops.getOrNull(destinationIndex) + val boardingIndex = ride.boardingStopIndex.takeIf { it >= 0 } + ?: session.boardingStopId + ?.let { id -> session.stops.indexOfFirst { it.stopId == id } } + ?.takeIf { it >= 0 } + ?: -1 + val boardingStop = session.stops.getOrNull(boardingIndex) + + val boardingLiveStop = boardingStop?.let { liveStops[it.stopId] } + // Prefer the vehicle the detector is following; fall back to whatever + // the stop-level feed says so the countdown still works where the ETA + // entries carry no plate (雙北). + val boardingVehicleId = ride.nextBusVehicleId + val boardingEtaText = displayEtaText(boardingLiveStop, boardingVehicleId) + val boardingEtaSeconds = resolveEtaSeconds(boardingLiveStop, boardingVehicleId) + val boardingEtaShort = displayShortEtaText(boardingLiveStop, boardingVehicleId) + val busStopsUntilBoarding = ride.busStopsUntilBoarding + val busStopsAway = nearestIndex.takeIf { it >= 0 }?.let { index -> + ride.trackedVehicleId?.let { tracked -> + findTrackedBusIndex( stops = session.stops, liveStops = liveStops, - currentTrackedBusId = trackedBusId ?: normalizeVehicleId(boardingVehicleId), - fallbackLiveStops = listOf( - userBusLiveStop, - nearestLiveStop, - boardingLiveStop, - ), + preferredVehicleId = tracked, ) - } else { - trackedBusLastSeenAtMs = 0L - null - } - val busStopsAway = busIndex?.let { (nearestIndex - it).coerceAtLeast(0) } - val boardingPointCount = boardingIndex + 1 - val boardingProgressPoint = if (rideState.hasBoarded) { - val trackedPos = boardingBusIndex ?: nearestIndex - (trackedPos + 1).coerceIn(1, boardingPointCount) - } else { - boardingBusIndex?.plus(1)?.coerceAtMost(boardingPointCount) ?: 0 - } - return TrackingSnapshot( - title = session.routeName, - content = "${nearestStop.stopName} 約 $nearestEtaText", - subText = buildNearestStatusText( - session = session, - nearestStop = nearestStop, - nearestEtaText = nearestEtaText, - busStopsAway = busStopsAway, - ), - progressMax = toProgressMax(boardingPointCount), - progressValue = toProgressValue(boardingProgressPoint, boardingPointCount), - shortCriticalText = buildShortCriticalText( - busStopsAway, - displayShortEtaText(nearestLiveStop), - ), - hasBoarded = rideState.hasBoarded, - boardingName = boardingStop.stopName, - boardingEtaText = boardingEtaText, - boardingEtaSeconds = boardingEtaSeconds, - boardingStopsAway = busStopsUntilBoarding, - boardingDistanceMeters = boardingDistanceMeters, - boardingVehicleId = boardingVehicleId, - boardingVehiclePassedStop = rideState.boardingVehiclePassedStop, - boardingPromptEligible = rideState.boardingPromptEligible, - nearestStopDistanceMeters = nearestStopDistanceMeters, - ) + }?.let { (index - it).coerceAtLeast(0) } } - if (destinationIndex == null) { - trackedBusId = null - trackedBusLastSeenAtMs = 0L - val busStopsAway = busIndex?.let { (nearestIndex - it).coerceAtLeast(0) } + if (boardingStop == null && destinationStop == null) { + // Neither end of the trip is known: just report the nearest stop. return TrackingSnapshot( title = session.routeName, - content = "${nearestStop.stopName} · $nearestEtaText", - subText = buildNearestStatusText( - session = session, - nearestStop = nearestStop, - nearestEtaText = nearestEtaText, - busStopsAway = busStopsAway, - ), + content = if (nearestStop != null) { + "${nearestStop.stopName} · $nearestEtaText" + } else { + session.pathName.ifBlank { "乘車提醒進行中" } + }, + subText = if (nearestStop != null) { + buildNearestStatusText(session, nearestStop, nearestEtaText, busStopsAway) + } else { + session.pathName.ifBlank { "背景乘車提醒進行中" } + }, progressMax = null, progressValue = null, shortCriticalText = buildShortCriticalText( busStopsAway, displayShortEtaText(nearestLiveStop), ), + nearestStopDistanceMeters = nearestStopDistanceMeters, ) } - val destinationStop = session.stops[destinationIndex] - val boardingIndex = resolveBoardingIndex(session, nearestIndex, destinationIndex) - val boardingStop = session.stops[boardingIndex] - val boardingLiveStop = liveStops[boardingStop.stopId] - val boardingVehicleCandidate = resolveBoardingVehicleCandidate( - stops = session.stops, - liveStops = liveStops, - location = location, - nearestIndex = nearestIndex, - boardingIndex = boardingIndex, - ) - val boardingVehicleId = boardingVehicleCandidate?.vehicleId ?: activeBoardingVehicleId - val boardingEtaText = displayEtaText(boardingLiveStop, boardingVehicleId) - val boardingEtaShort = displayShortEtaText(boardingLiveStop, boardingVehicleId) - val boardingEtaSeconds = resolveEtaSeconds(boardingLiveStop, boardingVehicleId) - val boardingDistanceMeters = distanceMeters( - location.latitude, - location.longitude, - boardingStop.lat, - boardingStop.lon, - ) - val fallbackBoardingBusIndex = findClosestBusIndexForBoarding( - stops = session.stops, - liveStops = liveStops, - boardingIndex = boardingIndex, - ) - val boardingBusIndex = - boardingVehicleCandidate?.stopIndex ?: activeBoardingVehicleStopIndex ?: fallbackBoardingBusIndex - val busStopsUntilBoarding = boardingBusIndex?.let { - (boardingIndex - it).coerceAtLeast(0) - } - val rideState = if (session.backgroundLocationAlwaysGranted) { - updateRideState( - location = location, - nearestIndex = nearestIndex, - boardingIndex = boardingIndex, - boardingBusIndex = boardingBusIndex, - boardingVehicleId = boardingVehicleId, - boardingEtaText = boardingEtaText, - boardingEtaSeconds = boardingEtaSeconds, - boardingDistanceMeters = boardingDistanceMeters, - ) - } else { - resolveNoLocationRideState( - busStopsUntilBoarding = busStopsUntilBoarding, - boardingEtaText = boardingEtaText, - boardingEtaSeconds = boardingEtaSeconds, - ) - } - - if (!rideState.hasBoarded) { - trackedBusId = null - trackedBusLastSeenAtMs = 0L - val boardingPointCount = boardingIndex + 1 - val boardingProgressPoint = - boardingBusIndex?.plus(1)?.coerceAtMost(boardingPointCount) ?: 0 + if (destinationStop == null || !ride.hasBoarded) { + val waitingStop = boardingStop ?: nearestStop + if (waitingStop == null) { + return TrackingSnapshot( + title = session.routeName, + content = "等待目前位置...", + subText = session.pathName.ifBlank { "背景乘車提醒進行中" }, + progressMax = null, + progressValue = null, + shortCriticalText = "定位中", + ) + } + val boardingPointCount = boardingIndex.takeIf { it >= 0 }?.plus(1) ?: 1 + val busPoint = ride.nextBusVehicleId + ?.let { tracked -> + findTrackedBusIndex( + stops = session.stops, + liveStops = liveStops, + preferredVehicleId = tracked, + ) + } + ?.plus(1) + ?.coerceAtMost(boardingPointCount) + ?: 0 return TrackingSnapshot( - title = "${session.routeName} · ${boardingStop.stopName}", - content = "公車到 ${boardingStop.stopName} 約 $boardingEtaText", - subText = buildWaitingBoardingText( - session = session, - boardingStop = boardingStop, - destinationStop = destinationStop, - busStopsUntilBoarding = busStopsUntilBoarding, - ), + title = if (destinationStop == null) { + session.routeName + } else { + "${session.routeName} · ${waitingStop.stopName}" + }, + content = if (destinationStop == null) { + "${waitingStop.stopName} 約 ${displayEtaText(liveStops[waitingStop.stopId])}" + } else { + "公車到 ${waitingStop.stopName} 約 $boardingEtaText" + }, + subText = if (destinationStop == null) { + buildNearestStatusText( + session = session, + nearestStop = waitingStop, + nearestEtaText = displayEtaText(liveStops[waitingStop.stopId]), + busStopsAway = busStopsUntilBoarding, + ) + } else { + buildWaitingBoardingText( + session = session, + boardingStop = waitingStop, + destinationStop = destinationStop, + busStopsUntilBoarding = busStopsUntilBoarding, + ) + }, progressMax = toProgressMax(boardingPointCount), - progressValue = toProgressValue(boardingProgressPoint, boardingPointCount), + progressValue = toProgressValue(busPoint, boardingPointCount), shortCriticalText = buildShortCriticalText( busStopsUntilBoarding, - boardingEtaShort, + if (destinationStop == null) { + displayShortEtaText(liveStops[waitingStop.stopId]) + } else { + boardingEtaShort + }, ), - boardingName = boardingStop.stopName, + hasBoarded = false, + boardingName = waitingStop.stopName, boardingEtaText = boardingEtaText, boardingEtaSeconds = boardingEtaSeconds, boardingStopsAway = busStopsUntilBoarding, - boardingDistanceMeters = boardingDistanceMeters, - hasBoarded = false, + boardingDistanceMeters = ride.boardingStopDistanceMeters, boardingVehicleId = boardingVehicleId, - boardingVehiclePassedStop = rideState.boardingVehiclePassedStop, - boardingPromptEligible = rideState.boardingPromptEligible, - destinationName = destinationStop.stopName, + boardingVehiclePassedStop = false, + boardingPromptEligible = ride.boardingPromptDue, + destinationName = destinationStop?.stopName, nearestStopDistanceMeters = nearestStopDistanceMeters, ) } + // On board: progress comes from the user's own position, falling back to + // the vehicle they are riding when the phone stops reporting. + val travelIndex = ride.progressStopIndex.takeIf { it >= 0 } + ?: ride.nearestStopIndexOnBoard(liveStops, session.stops) + ?: nearestIndex + val journeyStartIndex = boardingIndex.coerceAtLeast(0).coerceAtMost(destinationIndex) + val journeyPointCount = (destinationIndex - journeyStartIndex + 1).coerceAtLeast(1) + val currentProgressPoint = if (travelIndex >= 0) { + (travelIndex.coerceAtLeast(journeyStartIndex) - journeyStartIndex) + 1 + } else { + 1 + }.coerceIn(1, journeyPointCount) val destinationLive = liveStops[destinationStop.stopId] - val userBusLiveStop = busIndex?.let { liveStops[session.stops[it].stopId] } - trackedBusId = selectTrackedBusId( - stops = session.stops, - liveStops = liveStops, - currentTrackedBusId = trackedBusId ?: normalizeVehicleId(boardingVehicleId), - fallbackLiveStops = listOf( - userBusLiveStop, - nearestLiveStop, - boardingLiveStop, - destinationLive, - ), - ) - val trackedBusIndex = - findTrackedBusIndex( - stops = session.stops, - liveStops = liveStops, - preferredVehicleId = trackedBusId, - ) - // Once on board the user travels with the bus, so their own position is - // a better stand-in than some other vehicle when the tracked one drops - // out of the feed. - val travelIndex = trackedBusIndex ?: nearestIndex - val rawRemainingStops = destinationIndex - travelIndex - val remainingStops = rawRemainingStops.coerceAtLeast(0) - val allowAnyDestinationVehicle = trackedBusId == null val destinationEtaText = displayEtaText( destinationLive, - trackedBusId, - fallbackToAnyVehicle = allowAnyDestinationVehicle, + ride.trackedVehicleId, + fallbackToAnyVehicle = ride.trackedVehicleId == null, ) val destinationEtaShort = displayShortEtaText( destinationLive, - trackedBusId, - fallbackToAnyVehicle = allowAnyDestinationVehicle, - ) - val destinationDistanceMeters = distanceMeters( - location.latitude, - location.longitude, - destinationStop.lat, - destinationStop.lon, + ride.trackedVehicleId, + fallbackToAnyVehicle = ride.trackedVehicleId == null, ) - val journeyStartIndex = boardingIndex.coerceAtMost(destinationIndex) - val journeyPointCount = (destinationIndex - journeyStartIndex + 1).coerceAtLeast(1) - val currentProgressPoint = - ((travelIndex.coerceAtLeast(journeyStartIndex) - journeyStartIndex) + 1) - .coerceIn(1, journeyPointCount) + val remainingStops = ride.remainingStops ?: (destinationIndex - journeyStartIndex) + .coerceAtLeast(0) return TrackingSnapshot( title = "${session.routeName} · ${destinationStop.stopName}", @@ -837,519 +881,52 @@ class RouteTripMonitorService : Service() { remainingStops == 0 -> "已接近 ${destinationStop.stopName}" else -> "距離 ${destinationStop.stopName} 還有 $remainingStops 站 · $destinationEtaText" }, - subText = buildOnboardTripSubText(nearestStop.stopName), + subText = buildOnboardTripSubText( + nearestStop?.stopName ?: destinationStop.stopName, + ), progressMax = toProgressMax(journeyPointCount), progressValue = toProgressValue(currentProgressPoint, journeyPointCount), - shortCriticalText = buildShortCriticalText( - remainingStops, - destinationEtaShort, - ), + shortCriticalText = buildShortCriticalText(remainingStops, destinationEtaShort), hasBoarded = true, destinationName = destinationStop.stopName, remainingStops = remainingStops, - destinationDistanceMeters = destinationDistanceMeters, - boardingName = boardingStop.stopName, - boardingVehicleId = trackedBusId ?: boardingVehicleId, - passedDestinationByStops = (-rawRemainingStops).takeIf { it > 0 }, + destinationDistanceMeters = ride.destinationDistanceMeters, + boardingName = boardingStop?.stopName, + boardingVehicleId = ride.trackedVehicleId ?: boardingVehicleId, + passedDestinationByStops = ride.passedDestinationByStops, nearestStopDistanceMeters = nearestStopDistanceMeters, ) } - private fun buildSnapshotLegacy( - session: TrackingSession, - location: Location?, - ): TrackingSnapshot { - val liveStops = fetchLiveStopMap(session) - if (location == null) { - return TrackingSnapshot( - title = session.routeName, - content = "等待目前位置...", - subText = session.pathName.ifBlank { "背景乘車提醒進行中" }, - progressMax = null, - progressValue = null, - shortCriticalText = "定位中", - ) - } - - val nearestIndex = session.stops - .indices - .minByOrNull { index -> - val stop = session.stops[index] - distanceMeters( - location.latitude, - location.longitude, - stop.lat, - stop.lon, - ) - } ?: -1 - if (nearestIndex == -1) { - return TrackingSnapshot( - title = session.routeName, - content = "暫時無法判斷最近站牌", - subText = session.pathName.ifBlank { "背景乘車提醒進行中" }, - progressMax = null, - progressValue = null, - shortCriticalText = "更新中", - ) - } - - val nearestStop = session.stops[nearestIndex] - val nearestLiveStop = liveStops[nearestStop.stopId] - val busIndex = findClosestBusIndex(session.stops, liveStops, nearestIndex) - val busStopsAway = busIndex?.let { (nearestIndex - it).coerceAtLeast(0) } - val nearestEtaText = formatEtaText(nearestLiveStop) - val nearestEtaShort = formatShortEtaText(nearestLiveStop) - val nearestSubText = buildNearestSubText( - session = session, - nearestStop = nearestStop, - busStopsAway = busStopsAway, - nearestEtaText = nearestEtaText, - ) - - val destinationIndex = session.destinationStopId?.let { destinationStopId -> - session.stops.indexOfFirst { stop -> stop.stopId == destinationStopId } - .takeIf { it >= 0 } - } - if (destinationIndex == null) { - return TrackingSnapshot( - title = session.routeName, - content = "${nearestStop.stopName} · $nearestEtaText", - subText = nearestSubText, - progressMax = null, - progressValue = null, - shortCriticalText = buildShortCriticalText(busStopsAway, nearestEtaShort), - ) - } - - val destinationStop = session.stops[destinationIndex] - val destinationLive = liveStops[destinationStop.stopId] - val remainingStops = (destinationIndex - nearestIndex).coerceAtLeast(0) - val destinationEtaText = formatEtaText(destinationLive) - val destinationEtaShort = formatShortEtaText(destinationLive) - val destinationDistanceMeters = distanceMeters( - location.latitude, - location.longitude, - destinationStop.lat, - destinationStop.lon, - ) - val pointCount = destinationIndex + 1 - val currentProgressPoint = (busIndex ?: nearestIndex).plus(1).coerceAtMost(pointCount) - - return TrackingSnapshot( - title = "${session.routeName} · ${destinationStop.stopName}", - content = when { - remainingStops == 0 -> "已接近 ${destinationStop.stopName}" - else -> "${destinationStop.stopName} 還有 $remainingStops 站 · $destinationEtaText" - }, - subText = "最近站牌 ${nearestStop.stopName} · $nearestEtaText", - progressMax = toProgressMax(pointCount), - progressValue = toProgressValue(currentProgressPoint, pointCount), - destinationName = destinationStop.stopName, - remainingStops = remainingStops, - destinationDistanceMeters = destinationDistanceMeters, - shortCriticalText = buildShortCriticalText(remainingStops, destinationEtaShort), - ) - } - - private fun resolveBoardingIndex( - session: TrackingSession, - nearestIndex: Int, - destinationIndex: Int, - ): Int { - val explicitBoardingIndex = session.boardingStopId?.let { boardingStopId -> - session.stops.indexOfFirst { stop -> stop.stopId == boardingStopId } - .takeIf { it >= 0 } - } - return (explicitBoardingIndex ?: nearestIndex).coerceAtMost(destinationIndex) - } - - private fun findNearestStopIndex( - stops: List, - location: Location, - ): Int { - return stops.indices.minByOrNull { index -> - val stop = stops[index] - distanceMeters( - location.latitude, - location.longitude, - stop.lat, - stop.lon, - ) - } ?: -1 - } - - private fun findClosestStopIndices( - stops: List, - location: Location, - count: Int, - ): List { - if (count <= 0) { - return emptyList() - } - return stops.indices - .sortedBy { index -> - val stop = stops[index] - distanceMeters( - location.latitude, - location.longitude, - stop.lat, - stop.lon, - ) - } - .take(count) - } - - private fun resolveBoardingVehicleCandidate( - stops: List, - liveStops: Map, - location: Location, - nearestIndex: Int, - boardingIndex: Int, - ): VehicleCandidate? { - val now = System.currentTimeMillis() - val nearbyVehicleIds = linkedSetOf() - findClosestStopIndices(stops, location, USER_NEARBY_STOP_COUNT).forEach { index -> - nearbyVehicleIds += extractNormalizedVehicleIds(liveStops[stops[index].stopId]) - } - val normalizedActiveVehicleId = normalizeVehicleId(activeBoardingVehicleId) - val activeVehicleStillFresh = normalizedActiveVehicleId != null && - activeBoardingVehicleLastSeenAtMs != 0L && - now - activeBoardingVehicleLastSeenAtMs < ACTIVE_BOARDING_VEHICLE_STALE_MS - if (activeVehicleStillFresh) { - nearbyVehicleIds += normalizedActiveVehicleId!! - } - - val candidates = nearbyVehicleIds.mapNotNull { vehicleId -> - buildVehicleCandidate( - stops = stops, - liveStops = liveStops, - location = location, - vehicleId = vehicleId, - ) - } - val activeCandidate = candidates.firstOrNull { it.vehicleId == normalizedActiveVehicleId } - if (activeCandidate != null) { - activeBoardingVehicleStopIndex = activeCandidate.stopIndex - activeBoardingVehicleLastSeenAtMs = now - return activeCandidate - } - - // The locked vehicle dropped out of the live feed (bus GPS hiccups are - // routine). Hold the lock during the grace window instead of jumping - // to the next bus, so the original bus re-locks when it reappears. - if (activeVehicleStillFresh) { - return null - } - if (normalizedActiveVehicleId != null) { - activeBoardingVehicleId = null - activeBoardingVehicleStopIndex = null - activeBoardingVehicleLastSeenAtMs = 0L - activeBoardingVehiclePassedAtMs = 0L - } - - val selectedCandidate = candidates.minWithOrNull( - compareBy { if (it.stopIndex > boardingIndex) 1 else 0 } - .thenBy { - if (it.stopIndex <= boardingIndex) { - boardingIndex - it.stopIndex - } else { - it.stopIndex - boardingIndex - } - } - .thenBy { abs(it.stopIndex - nearestIndex) } - .thenBy { it.distanceMeters } - .thenBy { it.etaSeconds ?: Int.MAX_VALUE }, - ) ?: return null - - val nextVehicleId = selectedCandidate.vehicleId - if (normalizedActiveVehicleId != null && normalizedActiveVehicleId != nextVehicleId && !rideConfirmed) { - boardingAlertSent = false - boardingCheckPromptSent = false - boardingCheckPromptSentAtMs = 0L - boardingWindowOpen = false - activeBoardingVehiclePassedAtMs = 0L - rideConfirmationSamples = 0 - notificationManager.cancel(ALERT_NOTIFICATION_ID) - } - activeBoardingVehicleId = nextVehicleId - activeBoardingVehicleStopIndex = selectedCandidate.stopIndex - activeBoardingVehicleLastSeenAtMs = now - return selectedCandidate - } - - private fun buildVehicleCandidate( - stops: List, - liveStops: Map, - location: Location, - vehicleId: String, - ): VehicleCandidate? { - val normalizedVehicleId = normalizeVehicleId(vehicleId) ?: return null - val stopIndex = findTrackedBusIndex( - stops = stops, - liveStops = liveStops, - preferredVehicleId = normalizedVehicleId, - ) ?: return null - val stop = stops[stopIndex] - val stopState = liveStops[stop.stopId] - return VehicleCandidate( - vehicleId = normalizedVehicleId, - stopIndex = stopIndex, - distanceMeters = distanceMeters( - location.latitude, - location.longitude, - stop.lat, - stop.lon, - ), - etaSeconds = resolveEtaSeconds(stopState, normalizedVehicleId), - ) - } - - private fun extractNormalizedVehicleIds(stopState: LiveStopState?): Set { - val result = linkedSetOf() - stopState?.etaEntries?.forEach { etaEntry -> - normalizeVehicleId(etaEntry.vehicleId)?.let(result::add) - } - stopState?.vehicleIds?.forEach { vehicleId -> - normalizeVehicleId(vehicleId)?.let(result::add) - } - return result - } - - private fun updateRideState( - location: Location, - nearestIndex: Int, - boardingIndex: Int, - boardingBusIndex: Int?, - boardingVehicleId: String?, - boardingEtaText: String, - boardingEtaSeconds: Int?, - boardingDistanceMeters: Double, - ): RideStateUpdate { - val userNearBoardingStop = - boardingDistanceMeters <= BOARDING_STOP_RADIUS_METERS || nearestIndex == boardingIndex - val busNearBoardingStop = - ( - boardingBusIndex != null && - boardingBusIndex <= boardingIndex && - boardingIndex - boardingBusIndex <= 1 && - isLikelyBoardingArrival( - etaSeconds = boardingEtaSeconds, - etaText = boardingEtaText, - ) - ) || - isImmediateEtaText(boardingEtaText) - if (userNearBoardingStop && busNearBoardingStop) { - if (!boardingWindowOpen) { - boardingWindowOpenedAtMs = System.currentTimeMillis() - } - boardingWindowOpen = true - } else if (boardingWindowOpen && boardingWindowOpenedAtMs != 0L) { - val windowAge = System.currentTimeMillis() - boardingWindowOpenedAtMs - if (windowAge >= BOARDING_WINDOW_MAX_DURATION_MS) { - boardingWindowOpen = false - boardingWindowOpenedAtMs = 0L - } - } - - val movement = updateUserMovement(location, nearestIndex) - val previousNearest = lastNearestStopIndex - val previousBusIndex = lastBusStopIndex - if (movement.sampleAdvanced) { - lastNearestStopIndex = nearestIndex - } - lastBusStopIndex = boardingBusIndex - val movedForward = previousNearest != null && nearestIndex > previousNearest - val busMovedForward = - previousBusIndex != null && - boardingBusIndex != null && - boardingBusIndex > previousBusIndex - val busNearUser = boardingBusIndex != null && abs(nearestIndex - boardingBusIndex) <= 1 - val vehiclePassedBoarding = - normalizeVehicleId(boardingVehicleId) != null && - boardingBusIndex != null && - boardingBusIndex > boardingIndex - if (vehiclePassedBoarding) { - if (activeBoardingVehiclePassedAtMs == 0L) { - activeBoardingVehiclePassedAtMs = System.currentTimeMillis() - } - } else if (boardingBusIndex != null && boardingBusIndex <= boardingIndex) { - activeBoardingVehiclePassedAtMs = 0L - } - val userReachedBoarding = nearestIndex >= boardingIndex - val userAdvancedPastBoardingStop = nearestIndex > boardingIndex - val userMovingWithBus = - movement.isTransitLike && - (busNearUser || vehiclePassedBoarding) && - (movedForward || movement.routeIndexDelta > 0 || boardingDistanceMeters > BOARDING_CONFIRM_DISTANCE_METERS) - val strongBoardingSignal = - boardingWindowOpen && - userReachedBoarding && - vehiclePassedBoarding && - ( - userMovingWithBus || - ( - busMovedForward && - userAdvancedPastBoardingStop && - boardingDistanceMeters <= BOARDING_CONFIRM_DISTANCE_METERS - ) - ) - - if (!rideConfirmed && movement.sampleAdvanced) { - // Count evidence only on fresh GPS samples, and decay instead of - // hard-resetting so a single noisy fix cannot erase progress. - rideConfirmationSamples = if (strongBoardingSignal) { - rideConfirmationSamples + 1 - } else { - (rideConfirmationSamples - 1).coerceAtLeast(0) - } - if (rideConfirmationSamples >= REQUIRED_RIDE_CONFIRMATION_SAMPLES) { - rideConfirmed = true - boardingCheckPromptSent = true - boardingCheckPromptSentAtMs = System.currentTimeMillis() - normalizeVehicleId(boardingVehicleId)?.let { vehicleId -> - trackedBusId = vehicleId - trackedBusLastSeenAtMs = System.currentTimeMillis() - } - notificationManager.cancel(ALERT_NOTIFICATION_ID) - } - } - - val boardingPromptEligible = - !rideConfirmed && - boardingWindowOpen && - vehiclePassedBoarding && - !movement.isTransitLike && - activeBoardingVehiclePassedAtMs != 0L && - System.currentTimeMillis() - activeBoardingVehiclePassedAtMs >= - BOARDING_CHECK_AFTER_PASS_DELAY_MS - - return RideStateUpdate( - hasBoarded = rideConfirmed, - boardingVehiclePassedStop = vehiclePassedBoarding, - boardingPromptEligible = boardingPromptEligible, + /** + * Last resort for on-board progress when the user's own fixes are gone: the + * stop the tracked vehicle has reached, found through the live feed. + */ + private fun RideSnapshot.nearestStopIndexOnBoard( + liveStops: Map, + stops: List, + ): Int? { + val vehicleId = trackedVehicleId ?: return null + return findTrackedBusIndex( + stops = stops, + liveStops = liveStops, + preferredVehicleId = vehicleId, ) } - private fun updateUserMovement( + private fun findNearestStopIndex( + stops: List, location: Location, - nearestIndex: Int, - ): UserMovementState { - val now = SystemClock.elapsedRealtime() - val previousLocation = lastMovementLocation - - // Re-processing the same fix (refreshes happen more often than GPS - // updates) or a junk fix must not count as "the user stopped moving" — - // keep the previous verdict instead of resetting it. - val isSameFix = previousLocation != null && - previousLocation.time == location.time && - previousLocation.latitude == location.latitude && - previousLocation.longitude == location.longitude - val isPoorAccuracy = location.hasAccuracy() && - location.accuracy > MAX_USABLE_LOCATION_ACCURACY_METERS - if (isSameFix || isPoorAccuracy) { - return UserMovementState( - routeIndexDelta = 0, - isTransitLike = isTransitLikeStillFresh(now), - sampleAdvanced = false, - ) - } - - val previousNearestIndex = lastMovementNearestIndex - val previousRecordedAtMs = lastMovementRecordedAtMs - val distanceMovedMeters = if (previousLocation == null) { - 0.0 - } else { + ): Int { + return stops.indices.minByOrNull { index -> + val stop = stops[index] distanceMeters( - previousLocation.latitude, - previousLocation.longitude, location.latitude, location.longitude, + stop.lat, + stop.lon, ) - } - val elapsedMs = if (previousRecordedAtMs == 0L) 0L else now - previousRecordedAtMs - val estimatedSpeedMetersPerSecond = - elapsedMs.takeIf { it > 0L }?.let { distanceMovedMeters / (it / 1_000.0) } - // GPS chips report instantaneous speed far more reliably than the - // distance between two throttled notification refreshes. - val reportedSpeedMetersPerSecond = location.speed - .takeIf { location.hasSpeed() && it > 0f } - ?.toDouble() - val routeIndexDelta = if (previousNearestIndex == null) { - 0 - } else { - nearestIndex - previousNearestIndex - } - lastMovementLocation = Location(location) - lastMovementNearestIndex = nearestIndex - lastMovementRecordedAtMs = now - - val transitLikeEvidence = - ( - reportedSpeedMetersPerSecond != null && - reportedSpeedMetersPerSecond >= USER_TRANSIT_LIKE_SPEED_MPS - ) || - ( - estimatedSpeedMetersPerSecond != null && - distanceMovedMeters >= USER_MOVEMENT_MIN_DISTANCE_METERS && - estimatedSpeedMetersPerSecond >= USER_TRANSIT_LIKE_SPEED_MPS - ) || - ( - routeIndexDelta > 0 && - distanceMovedMeters >= USER_ROUTE_PROGRESS_MIN_DISTANCE_METERS - ) - if (transitLikeEvidence) { - lastTransitLikeAtMs = now - } - return UserMovementState( - routeIndexDelta = routeIndexDelta, - // Buses stop at lights and stations; hold the transit verdict for a - // short window instead of flapping on every slow sample. - isTransitLike = transitLikeEvidence || isTransitLikeStillFresh(now), - sampleAdvanced = true, - ) - } - - private fun isTransitLikeStillFresh(nowElapsedMs: Long): Boolean { - return lastTransitLikeAtMs != 0L && - nowElapsedMs - lastTransitLikeAtMs < TRANSIT_LIKE_STICKY_MS - } - - private fun hasBusReachedBoardingStop( - busStopsUntilBoarding: Int?, - boardingEtaText: String, - boardingEtaSeconds: Int?, - ): Boolean { - if (busStopsUntilBoarding == null || busStopsUntilBoarding > 0) { - return false - } - if (isImmediateEtaText(boardingEtaText)) { - return true - } - // Without background location we cannot verify the user actually got - // on, so only treat the bus as boarded when it is genuinely at the - // stop — a 3-minute ETA is way too early. - return boardingEtaSeconds != null && - boardingEtaSeconds <= BOARDING_NO_LOCATION_MAX_ETA_SECONDS - } - - private fun resolveNoLocationRideState( - busStopsUntilBoarding: Int?, - boardingEtaText: String, - boardingEtaSeconds: Int?, - ): RideStateUpdate { - // Latch the boarded state: once the tracked bus reaches the boarding - // stop the ETA feed rolls over to the NEXT bus, which used to flip the - // notification back to "not boarded" until the user re-confirmed. - if (!rideConfirmed && - hasBusReachedBoardingStop( - busStopsUntilBoarding = busStopsUntilBoarding, - boardingEtaText = boardingEtaText, - boardingEtaSeconds = boardingEtaSeconds, - ) - ) { - rideConfirmed = true - } - return RideStateUpdate(hasBoarded = rideConfirmed) + } ?: -1 } private fun buildNearestStatusText( @@ -1419,70 +996,6 @@ class RouteTripMonitorService : Service() { ) } - private fun selectTrackedBusId( - stops: List, - liveStops: Map, - currentTrackedBusId: String?, - fallbackLiveStops: List, - ): String? { - val now = System.currentTimeMillis() - val normalizedCurrent = normalizeVehicleId(currentTrackedBusId) - if (normalizedCurrent != null) { - val seenAnywhereOnRoute = findTrackedBusIndex( - stops = stops, - liveStops = liveStops, - preferredVehicleId = normalizedCurrent, - ) != null - if (seenAnywhereOnRoute) { - trackedBusLastSeenAtMs = now - return normalizedCurrent - } - if (trackedBusLastSeenAtMs == 0L) { - trackedBusLastSeenAtMs = now - return normalizedCurrent - } - // Vehicle temporarily missing from the live feed — keep the lock - // during the grace window so it re-attaches when the feed recovers - // instead of hopping to the next bus. - if (now - trackedBusLastSeenAtMs < TRACKED_BUS_STALE_MS) { - return normalizedCurrent - } - } - - fallbackLiveStops.forEach { stopState -> - firstKnownVehicleId(stopState)?.let { vehicleId -> - trackedBusLastSeenAtMs = now - return vehicleId - } - } - - trackedBusLastSeenAtMs = 0L - return null - } - - private fun firstKnownVehicleId(stopState: LiveStopState?): String? { - stopState ?: return null - stopState.etaEntries.firstNotNullOfOrNull { entry -> - normalizeVehicleId(entry.vehicleId) - }?.let { return it } - return stopState.vehicleIds.firstNotNullOfOrNull { vehicleId -> - normalizeVehicleId(vehicleId) - } - } - - private fun isVehicleSeenAtStop( - stopState: LiveStopState?, - normalizedVehicleId: String, - ): Boolean { - stopState ?: return false - if (stopState.vehicleIds.any { normalizeVehicleId(it) == normalizedVehicleId }) { - return true - } - return stopState.etaEntries.any { etaEntry -> - normalizeVehicleId(etaEntry.vehicleId) == normalizedVehicleId - } - } - private fun findEtaForVehicle( stopState: LiveStopState?, preferredVehicleId: String?, @@ -1512,16 +1025,6 @@ class RouteTripMonitorService : Service() { return selectedEta?.seconds ?: stopState?.seconds } - private fun isLikelyBoardingArrival( - etaSeconds: Int?, - etaText: String?, - ): Boolean { - if (isImmediateEtaText(etaText)) { - return true - } - return etaSeconds != null && etaSeconds <= BOARDING_ARRIVAL_MAX_ETA_SECONDS - } - private fun composeEtaText(seconds: Int?, message: String?): String { val trimmedMessage = message?.trim().orEmpty() if (trimmedMessage.isNotEmpty()) { @@ -1571,22 +1074,22 @@ class RouteTripMonitorService : Service() { return value.contains("進站") || value.contains("即將") } - private fun buildNearestSubText( - session: TrackingSession, - nearestStop: TrackingStop, - busStopsAway: Int?, - nearestEtaText: String, - ): String { - val parts = mutableListOf() - if (session.pathName.isNotBlank()) { - parts += session.pathName - } - parts += "最近站牌 ${nearestStop.stopName}" - if (nearestEtaText != "--") { - parts += nearestEtaText + /** + * The button that lets the user answer the boarding question by hand. It is + * offered while waiting for the bus as well, so detection is never the only + * way to start a trip. + */ + private fun TrackingSnapshot.boardingActions(): String? { + return when { + hasBoarded -> null + boardingName != null -> "我已上車" + else -> null } - buildBusDistanceText(busStopsAway)?.let(parts::add) - return parts.joinToString(" · ") + } + + /** The counterpart button, offered once a ride is being tracked. */ + private fun TrackingSnapshot.notBoardedAction(): String? { + return if (hasBoarded) "還沒上車" else null } private fun buildTrackingNotification(snapshot: TrackingSnapshot): Notification { @@ -1705,6 +1208,17 @@ class RouteTripMonitorService : Service() { ).build(), ) + snapshot.boardingActions()?.let { action -> + builder.addAction( + NotificationCompat.Action.Builder(0, action, markBoardedIntent).build(), + ) + } + snapshot.notBoardedAction()?.let { action -> + builder.addAction( + NotificationCompat.Action.Builder(0, action, notBoardedIntent).build(), + ) + } + applyShortCriticalPresentation(builder, snapshot.shortCriticalText) requestPromotedOngoing(builder) @@ -1780,6 +1294,13 @@ class RouteTripMonitorService : Service() { ).build(), ) + snapshot.boardingActions()?.let { action -> + builder.addAction(Notification.Action.Builder(null, action, markBoardedIntent).build()) + } + snapshot.notBoardedAction()?.let { action -> + builder.addAction(Notification.Action.Builder(null, action, notBoardedIntent).build()) + } + applyShortCriticalPresentation(builder, snapshot.shortCriticalText) requestPromotedOngoing(builder) @@ -1849,74 +1370,6 @@ class RouteTripMonitorService : Service() { ) } - private fun buildLegacyTrackingNotification(snapshot: TrackingSnapshot): android.app.Notification { - val currentSession = session ?: return NotificationCompat.Builder( - this, - TRACKING_CHANNEL_ID, - ).setSmallIcon(R.drawable.ic_status_bus) - .setContentTitle("YABus") - .setOnlyAlertOnce(true) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .build() - - val builder = NotificationCompat.Builder(this, TRACKING_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_status_bus) - .setContentTitle(snapshot.title) - .setContentText(snapshot.content) - .setContentIntent(createOpenRoutePendingIntent(currentSession)) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setCategory(NotificationCompat.CATEGORY_NAVIGATION) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setRequestPromotedOngoing(true) - .addAction( - NotificationCompat.Action.Builder( - 0, - "停止", - createStopPendingIntent(), - ).build(), - ) - - if (snapshot.subText.isNotBlank()) { - builder.setSubText(snapshot.subText) - } - applyShortCriticalPresentation(builder, snapshot.shortCriticalText) - - val progressMax = snapshot.progressMax - val progressValue = snapshot.progressValue - if (progressMax != null && progressValue != null && progressMax > 0) { - val clampedValue = progressValue.coerceIn(0, progressMax) - val progressPoints = buildProgressPointPositions(progressMax) - builder.setProgress(progressMax, clampedValue, false) - val progressStyle = NotificationCompat.ProgressStyle() - .setStyledByProgress(true) - .setProgress(clampedValue) - .setProgressTrackerIcon( - IconCompat.createWithResource(this, R.drawable.ic_progress_bus), - ) - .setProgressSegments( - mutableListOf( - NotificationCompat.ProgressStyle.Segment(progressMax), - ), - ) - .setProgressPoints( - progressPoints.mapTo(mutableListOf()) { position -> - NotificationCompat.ProgressStyle.Point(position) - .setColor(PROGRESS_POINT_COLOR) - }, - ) - .setProgressEndIcon( - IconCompat.createWithResource(this, R.drawable.ic_progress_flag), - ) - builder.setStyle(progressStyle) - } else { - builder.setProgress(0, 0, false) - } - - return builder.build() - } - private fun maybeSendTripAlerts( session: TrackingSession, snapshot: TrackingSnapshot, @@ -1944,11 +1397,11 @@ class RouteTripMonitorService : Service() { return } if (!snapshot.hasBoarded) { - maybeExpireBoardingCheckPrompt() maybeSendBoardingAlert(session, snapshot) maybeSendBoardingCheckPrompt(session, snapshot) return } + cancelBoardingCheckPrompt() maybeSendDestinationAlert(session, snapshot) maybePauseAfterArrivalGrace() } @@ -1999,49 +1452,30 @@ class RouteTripMonitorService : Service() { ) } - private fun maybeExpireBoardingCheckPrompt() { - if (!boardingCheckPromptSent || boardingCheckPromptSentAtMs == 0L) { - return - } - val now = System.currentTimeMillis() - if (now - boardingCheckPromptSentAtMs < BOARDING_CHECK_PROMPT_MAX_AGE_MS) { - return - } - if (!boardingWindowOpen) { - boardingCheckPromptSent = false - boardingCheckPromptSentAtMs = 0L - boardingAlertSent = false - notificationManager.cancel(ALERT_NOTIFICATION_ID) - } - } - private fun maybeSendBoardingCheckPrompt( session: TrackingSession, snapshot: TrackingSnapshot, ) { - if (!session.backgroundLocationAlwaysGranted || snapshot.hasBoarded) { - return - } - if (boardingCheckPromptSent) { - return - } - if (!snapshot.boardingPromptEligible) { + val ride = currentRideSnapshot() + if (!ride.boardingPromptDue || ride.boardingCheckId == 0L) { + // The question was answered, retired or overtaken by a detection. + if (snapshot.hasBoarded || ride.boardingCheckId == 0L) { + cancelBoardingCheckPrompt() + } return } - val now = System.currentTimeMillis() - if (now < boardingCheckSnoozeUntilMs) { + if (boardingCheckNotificationId == ride.boardingCheckId) { return } val boardingName = snapshot.boardingName ?: return - boardingCheckPromptSent = true - boardingCheckPromptSentAtMs = System.currentTimeMillis() + boardingCheckNotificationId = ride.boardingCheckId notificationManager.notify( - ALERT_NOTIFICATION_ID, + BOARDING_CHECK_NOTIFICATION_ID, NotificationCompat.Builder(this, ALERT_CHANNEL_ID) .setSmallIcon(R.drawable.ic_status_bus) .setContentTitle("你有上車嗎?") - .setContentText("$boardingName 的那台車已經過站了。") + .setContentText("$boardingName 的公車剛剛離站了。") .setSubText(session.pathName) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_REMINDER) @@ -2066,6 +1500,11 @@ class RouteTripMonitorService : Service() { ) } + private fun cancelBoardingCheckPrompt() { + boardingCheckNotificationId = 0L + notificationManager.cancel(BOARDING_CHECK_NOTIFICATION_ID) + } + private fun maybeSendDestinationSetupPrompt( session: TrackingSession, snapshot: TrackingSnapshot, @@ -2096,6 +1535,12 @@ class RouteTripMonitorService : Service() { ) } + /** + * Two heads-up alerts. "Stops remaining" counts the destination itself, so + * one remaining means the next stop is the user's — that, rather than a + * fixed distance, is what should prompt them to get ready, because a + * long segment can easily run past a couple of hundred metres. + */ private fun maybeSendDestinationAlert( session: TrackingSession, snapshot: TrackingSnapshot, @@ -2104,6 +1549,7 @@ class RouteTripMonitorService : Service() { val remainingStops = snapshot.remainingStops ?: return val distanceMeters = snapshot.destinationDistanceMeters ?: return val now = System.currentTimeMillis() + val nextStopIsDestination = remainingStops <= 1 if (remainingStops <= 2 && destinationAlertStage < 1) { destinationAlertStage = 1 @@ -2136,9 +1582,10 @@ class RouteTripMonitorService : Service() { ) } - if ((remainingStops == 0 || distanceMeters <= 120.0) && destinationAlertStage < 2) { + if ((nextStopIsDestination || distanceMeters <= DESTINATION_ARRIVED_DISTANCE_METERS) && + destinationAlertStage < 2 + ) { destinationAlertStage = 2 - arrivalDetectedAtMs = now notificationManager.notify( ALERT_NOTIFICATION_ID, NotificationCompat.Builder(this, ALERT_CHANNEL_ID) @@ -2166,7 +1613,13 @@ class RouteTripMonitorService : Service() { .setContentIntent(createOpenRoutePendingIntent(session)) .build(), ) - } else if (destinationAlertStage >= 2 && arrivalDetectedAtMs == 0L) { + } + + // Arrival — not "the next stop is yours" — is what starts the countdown + // to the auto-pause and the overshoot check. + if (arrivalDetectedAtMs == 0L && + (remainingStops == 0 || distanceMeters <= DESTINATION_ARRIVED_DISTANCE_METERS) + ) { arrivalDetectedAtMs = now } } @@ -2344,26 +1797,7 @@ class RouteTripMonitorService : Service() { foregroundStarted = false session = null latestLocation = null - lastMovementLocation = null - lastMovementNearestIndex = null - lastMovementRecordedAtMs = 0L - lastTransitLikeAtMs = 0L boardingAlertSent = false - boardingCheckPromptSent = false - boardingCheckPromptSentAtMs = 0L - boardingWindowOpen = false - boardingWindowOpenedAtMs = 0L - boardingCheckSnoozeUntilMs = 0L - rideConfirmed = false - rideConfirmationSamples = 0 - lastNearestStopIndex = null - lastBusStopIndex = null - activeBoardingVehicleId = null - activeBoardingVehicleStopIndex = null - activeBoardingVehicleLastSeenAtMs = 0L - activeBoardingVehiclePassedAtMs = 0L - trackedBusId = null - trackedBusLastSeenAtMs = 0L destinationSetupPromptSent = false arrivalDetectedAtMs = 0L destinationAlertStage = 0 @@ -2375,6 +1809,16 @@ class RouteTripMonitorService : Service() { cachedLivePathId = null cachedLiveFetchedAtMs = 0L cachedLiveStops = emptyMap() + cachedBusesRouteId = null + cachedBusesFetchedAtMs = 0L + cachedBusReports = emptyList() + liveFetchBackoffUntilMs = 0L + synchronized(detectorLock) { + detector = null + latestRide = RideSnapshot() + } + cancelBoardingCheckPrompt() + releaseWakeLock() mainHandler.removeCallbacksAndMessages(null) runCatching { if (::fusedLocationClient.isInitialized) { @@ -2393,49 +1837,80 @@ class RouteTripMonitorService : Service() { } } + /** + * The cached stop feed. Fetching happens in [updateLiveData] so that the + * snapshot builder never blocks on the network and both feeds are + * throttled together. + */ private fun fetchLiveStopMap(session: TrackingSession): Map { - val routeIds = session.realtimeRouteIds - if (routeIds.isEmpty()) { + if (session.realtimeRouteIds.isEmpty()) { return emptyMap() } - val now = SystemClock.elapsedRealtime() - synchronized(refreshLock) { - if ( - appInForeground && - cachedLiveRouteIds == routeIds && - cachedLivePathId == session.pathId - ) { - return cachedLiveStops - } - if ( - cachedLiveRouteIds == routeIds && - cachedLivePathId == session.pathId && - cachedLiveFetchedAtMs != 0L && - now - cachedLiveFetchedAtMs < LIVE_STOP_CACHE_TTL_MS - ) { - return cachedLiveStops + return synchronized(refreshLock) { + if (cachedLiveRouteIds == session.realtimeRouteIds && cachedLivePathId == session.pathId) { + cachedLiveStops + } else { + emptyMap() } } - if (appInForeground) { + } + + /** The live feed for the route and its variants; empty when fetching failed. */ + private fun fetchLiveStopMapFromBackend( + session: TrackingSession, + ): Map { + if (session.realtimeRouteIds.isEmpty()) { return emptyMap() } - val liveStops = familyRealtime.fetch(session) - synchronized(refreshLock) { - if (this.session === session) { - cachedLiveRouteIds = routeIds - cachedLivePathId = session.pathId - cachedLiveFetchedAtMs = now - cachedLiveStops = liveStops + // The family fetch covers the route and its variants; a single-route + // session is just the one-id case of it. + return familyRealtime.fetch(session) + } + + /** Live positions of every vehicle on the route; null when the request failed. */ + private fun fetchBusReportsFromBackend(routeId: String): List? { + val encodedRouteId = URLEncoder.encode(routeId, Charsets.UTF_8.name()) + val array = fetchBackendJsonArray("/api/v1/routes/$encodedRouteId/realtime/buses") + ?: return null + val receivedAtMs = System.currentTimeMillis() + return runCatching { parseBusReports(array, receivedAtMs) }.getOrNull() + } + + private fun parseBusReports( + array: JSONArray, + receivedAtMs: Long, + ): List { + val reports = ArrayList(array.length()) + for (index in 0 until array.length()) { + val bus = array.optJSONObject(index) ?: continue + val vehicleId = normalizeVehicleId(bus.opt("id")?.toString()) + ?: normalizeVehicleId(bus.opt("plate")?.toString()) + ?: continue + val latitude = bus.optDouble("lat", Double.NaN) + val longitude = bus.optDouble("lon", Double.NaN) + if (latitude.isNaN() || longitude.isNaN()) { + continue } + // GPSTime is unix seconds; a missing one makes the report unusable + // because there is nothing to line the user's track up against. + val gpsTimeMs = (toIntOrNull(bus.opt("time"))?.toLong() ?: continue) * 1000L + reports += RideBusReport( + vehicleId = vehicleId, + pathId = toIntOrNull(bus.opt("direction")), + latitude = latitude, + longitude = longitude, + gpsTimeMs = gpsTimeMs, + receivedAtMs = receivedAtMs, + ) } - return liveStops + return reports } - private fun fetchBackendJson(path: String): JSONObject? { + /** Raw body of a backend GET; null when the request failed. */ + private fun fetchBackendJsonText(path: String): String? { var connection: HttpURLConnection? = null return try { - connection = URL("https://bus.avianjay.sbs/api/v1/$path") - .openConnection() as HttpURLConnection + connection = URL("$BACKEND_BASE_URL$path").openConnection() as HttpURLConnection connection.connectTimeout = 10_000 connection.readTimeout = 10_000 connection.requestMethod = "GET" @@ -2446,10 +1921,9 @@ class RouteTripMonitorService : Service() { if (connection.responseCode !in 200..299) { return null } - val jsonText = connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> reader.readText() } - JSONObject(jsonText) } catch (_: Exception) { null } finally { @@ -2457,66 +1931,24 @@ class RouteTripMonitorService : Service() { } } - private fun formatEtaText(liveStopState: LiveStopState?): String { - liveStopState ?: return "--" - val message = normalizeEtaMessage(liveStopState.message) - if (message != null) { - return message - } - - val seconds = liveStopState.seconds ?: return "--" - if (seconds <= 0) { - return "進站中" - } - if (seconds < 60) { - return "即將進站" - } - return "${seconds / 60} 分" - } - - private fun formatShortEtaText(liveStopState: LiveStopState?): String { - liveStopState ?: return "--" - val message = liveStopState.message?.trim().orEmpty() - if (message.isNotEmpty()) { - return when { - message.contains("進站") || message.contains("到站") -> "進站" - message.contains("即將") -> "即將" - message.contains("未發車") -> "未發" - message.contains("末班") -> "末班" - else -> message.take(6) - } - } - - val seconds = liveStopState.seconds ?: return "--" - if (seconds <= 0) { - return "進站" - } - if (seconds < 60) { - return "<1分" + /** A JSON object response, addressed relative to `/api/v1/`. */ + private fun fetchBackendJson(path: String): JSONObject? { + return fetchBackendJsonText("/api/v1/$path")?.let { text -> + runCatching { JSONObject(text) }.getOrNull() } - return "${seconds / 60}分" } - private fun normalizeEtaMessage(message: String?): String? { - val trimmed = message?.trim().orEmpty() - if (trimmed.isEmpty()) { - return null - } - - return when { - trimmed.contains("進站") || trimmed.contains("到站") -> "進站中" - trimmed.contains("即將") -> "即將進站" - trimmed.contains("未發車") -> "未發車" - trimmed.contains("末班") -> "末班已過" - else -> trimmed + /** A JSON array response; `/realtime/buses` returns one. */ + private fun fetchBackendJsonArray(path: String): JSONArray? { + return fetchBackendJsonText(path)?.let { text -> + runCatching { JSONArray(text) }.getOrNull() } } - private fun buildBusDistanceText(busStopsAway: Int?): String? { - return when (busStopsAway) { - null -> null - 0 -> "公車就在附近" - else -> "公車還有 $busStopsAway 站" + private fun toIntOrNull(raw: Any?): Int? { + return when (raw) { + is Number -> raw.toInt() + else -> raw?.toString()?.trim()?.toIntOrNull() } } @@ -2548,67 +1980,7 @@ class RouteTripMonitorService : Service() { return trimmed.take(7) } - private fun buildLegacyShortCriticalText(stopsAway: Int?, etaText: String): String? { - if (stopsAway == null && etaText == "--") { - return null - } - - if (stopsAway == 0 && etaText != "--") { - return etaText.take(7) - } - - val left = when (stopsAway) { - null -> null - 0 -> "到站" - else -> "${stopsAway}站" - } - if (left == null) { - return etaText - } - if (etaText == "--") { - return left - } - return "$left | $etaText" - } - - private fun findClosestBusIndex( - stops: List, - liveStops: Map, - nearestIndex: Int, - ): Int? { - val busIndexes = stops.mapIndexedNotNull { index, stop -> - val liveStop = liveStops[stop.stopId] ?: return@mapIndexedNotNull null - if (isBusApproachingStop(liveStop)) { - index - } else { - null - } - } - val behindOrAtUser = busIndexes.filter { it <= nearestIndex } - if (behindOrAtUser.isNotEmpty()) { - return behindOrAtUser.maxOrNull() - } - return busIndexes.minOrNull() - } - - private fun findClosestBusIndexForBoarding( - stops: List, - liveStops: Map, - boardingIndex: Int, - ): Int? { - val busIndexes = stops.mapIndexedNotNull { index, stop -> - val liveStop = liveStops[stop.stopId] ?: return@mapIndexedNotNull null - if (isBusApproachingStop(liveStop)) { - index - } else { - null - } - } - return busIndexes - .filter { it <= boardingIndex } - .maxOrNull() - } - + /** Index of the stop where the live feed last saw [preferredVehicleId]. */ private fun findTrackedBusIndex( stops: List, liveStops: Map, @@ -2616,21 +1988,17 @@ class RouteTripMonitorService : Service() { ): Int? { val normalizedVehicleId = normalizeVehicleId(preferredVehicleId) ?: return null return stops.indexOfFirst { stop -> - isVehicleSeenAtStop( - stopState = liveStops[stop.stopId], - normalizedVehicleId = normalizedVehicleId, - ) + val stopState = liveStops[stop.stopId] + stopState != null && + ( + stopState.vehicleIds.any { normalizeVehicleId(it) == normalizedVehicleId } || + stopState.etaEntries.any { + normalizeVehicleId(it.vehicleId) == normalizedVehicleId + } + ) }.takeIf { it >= 0 } } - private fun isBusApproachingStop(liveStop: LiveStopState): Boolean { - val message = liveStop.message?.trim().orEmpty() - return liveStop.vehicleIds.isNotEmpty() || - (liveStop.seconds != null && liveStop.seconds <= 0) || - message.contains("進站") || - message.contains("到站") - } - private fun distanceMeters( lat1: Double, lon1: Double, @@ -2684,38 +2052,28 @@ class RouteTripMonitorService : Service() { private const val ALERT_CHANNEL_ID = "trip_monitor_alerts" private const val TRACKING_NOTIFICATION_ID = 6021 private const val ALERT_NOTIFICATION_ID = 6022 + private const val BOARDING_CHECK_NOTIFICATION_ID = 6023 + private const val BACKEND_BASE_URL = "https://bus.avianjay.sbs" private const val POLL_INTERVAL_MS = 15_000L + private const val LIVE_STOP_REFRESH_INTERVAL_MS = 20_000L + private const val LIVE_BUS_REFRESH_INTERVAL_MS = 15_000L + private const val LIVE_FETCH_BACKOFF_MS = 60_000L private const val MIN_REFRESH_INTERVAL_MS = 2_000L - private const val LIVE_STOP_CACHE_TTL_MS = 2_000L private const val LOCATION_UPDATE_INTERVAL_MS = 12_000L private const val LOCATION_MIN_UPDATE_INTERVAL_MS = 6_000L - private const val BOARDING_STOP_RADIUS_METERS = 180.0 - private const val BOARDING_CONFIRM_DISTANCE_METERS = 250.0 - private const val BOARDING_ARRIVAL_MAX_ETA_SECONDS = 180 - private const val BOARDING_NO_LOCATION_MAX_ETA_SECONDS = 40 - private const val BOARDING_CHECK_PROMPT_MAX_ETA_SECONDS = 180 - private const val REQUIRED_RIDE_CONFIRMATION_SAMPLES = 2 private const val BACKGROUND_AUTO_PAUSE_GRACE_MS = 90_000L - private const val BOARDING_CHECK_PROMPT_DELAY_MS = 45_000L - private const val BOARDING_CHECK_SNOOZE_MS = 180_000L - private const val BOARDING_CHECK_AFTER_PASS_DELAY_MS = 20_000L - private const val ACTIVE_BOARDING_VEHICLE_STALE_MS = 90_000L - private const val TRACKED_BUS_STALE_MS = 120_000L - private const val BOARDING_WINDOW_MAX_DURATION_MS = 300_000L - private const val BOARDING_CHECK_PROMPT_MAX_AGE_MS = 300_000L - private const val USER_NEARBY_STOP_COUNT = 2 - private const val USER_MOVEMENT_MIN_DISTANCE_METERS = 35.0 - private const val USER_ROUTE_PROGRESS_MIN_DISTANCE_METERS = 45.0 - private const val USER_TRANSIT_LIKE_SPEED_MPS = 4.2 - private const val MAX_USABLE_LOCATION_ACCURACY_METERS = 100f - private const val TRANSIT_LIKE_STICKY_MS = 90_000L + private const val WAKE_LOCK_TAG = "YABus::TripMonitor" + // The lock is renewed on every start, so this only bounds a session that + // was never cleaned up; it is not the expected trip length. + private const val WAKE_LOCK_TIMEOUT_MS = 6 * 60 * 60 * 1000L private const val PROGRESS_POINT_UNIT = 100 private const val PROGRESS_POINT_COLOR = 0x80000000.toInt() private const val OVERSHOOT_CONFIRM_DELAY_MS = 45_000L private const val ARRIVAL_AUTO_PAUSE_DELAY_MS = 60_000L private const val ROUTE_PROXIMITY_MAX_DISTANCE_METERS = 100.0 private const val DESTINATION_OVERSHOOT_DISTANCE_METERS = 100.0 + private const val DESTINATION_ARRIVED_DISTANCE_METERS = 120.0 private const val LIVE_UPDATE_SDK_INT = 36 private const val PAUSE_REASON_USER = "user" private const val PAUSE_REASON_ARRIVED = "arrived" @@ -2733,7 +2091,9 @@ class RouteTripMonitorService : Service() { for (index in 0 until stopsJson.length()) { val stop = stopsJson.optJSONObject(index) ?: continue stops += TrackingStop( - stopId = stop.optInt("stopId", 0), + // Must match how the live feed folds the same id, or the + // two maps never line up for 64-bit ids (高雄). + stopId = foldStopId(stop.opt("stopId")), rawStopId = stop.optString("rawStopId", "") .trim().takeIf { it.isNotEmpty() && it != "null" }, stopName = stop.optString("stopName", ""), @@ -2767,11 +2127,11 @@ class RouteTripMonitorService : Service() { .takeUnless { it.isNaN() }, initialLongitude = root.optDouble("initialLongitude", Double.NaN) .takeUnless { it.isNaN() }, - boardingStopId = root.optInt("boardingStopId", -1) + boardingStopId = foldStopId(root.opt("boardingStopId")) .takeIf { it > 0 }, boardingStopName = root.optString("boardingStopName", "") .takeIf { it.isNotBlank() }, - destinationStopId = root.optInt("destinationStopId", -1) + destinationStopId = foldStopId(root.opt("destinationStopId")) .takeIf { it > 0 }, destinationStopName = root.optString("destinationStopName", "") .takeIf { it.isNotBlank() }, diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt index db3b5e7..b5e1b5b 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt @@ -622,7 +622,10 @@ internal object SmartRouteNotificationSupport { while (cursor.moveToNext()) { result += SmartRouteStop( pathId = cursor.getInt(0), - stopId = cursor.getInt(1), + // stopid is a TEXT column and 高雄 ids exceed Int; reading it + // with getInt would truncate the key the live feed is folded + // with, and the two would never meet. + stopId = foldStopId(cursor.getString(1)), stopName = cursor.getString(2).orEmpty(), sequence = cursor.getInt(3), lon = cursor.getDouble(4), @@ -678,21 +681,7 @@ internal object SmartRouteNotificationSupport { return result } - private fun parseStopId(raw: Any?): Int { - if (raw is Number) { - return raw.toInt() - } - val text = raw?.toString()?.trim().orEmpty() - val parsed = text.toIntOrNull() - if (parsed != null) { - return parsed - } - var hash = 17 - for (char in text) { - hash = (hash * 31 + char.code) and 0x7fffffff - } - return hash - } + private fun parseStopId(raw: Any?): Int = foldStopId(raw) private fun toIntOrNull(raw: Any?): Int? { return when (raw) { diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt new file mode 100644 index 0000000..66027f2 --- /dev/null +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt @@ -0,0 +1,45 @@ +package tw.avianjay.taiwanbus.flutter + +/** + * Folds a stop id from any source into the single positive `Int` the app keys + * stops by. + * + * Stop ids are 64-bit in the feeds (高雄's look like `100100128221`) but every + * stop map in the app is keyed by `Int`, and the two sides of that key used to + * disagree: the session payload was read with `optInt`, which wraps to the low + * 32 bits, while the live feed fell back to hashing the id as text. The two + * values can never be equal, so a 高雄 stop's live ETA was unreachable — and + * ids that wrapped negative were dropped by the `> 0` filters as well. + * + * Both sides call this now. Values that already fit in 31 bits pass through + * untouched, so 台中 and 雙北 ids are unaffected, and the fold is idempotent, + * so a value that has been through it stays put. It never returns a negative + * number, which is what makes the `> 0` filters safe. Real ids do not collide + * under it, and the hash fallback still covers the non-numeric ids some + * providers use (`101-11009`). + * + * Returns 0 for a missing or blank id so callers can keep filtering on `> 0`. + */ +internal fun foldStopId(raw: Any?): Int { + val text = when (raw) { + // JSON numbers can arrive as any boxed type; going through Long keeps + // an integral id from being mangled into exponent notation. + is Number -> raw.toLong().toString() + else -> raw?.toString()?.trim().orEmpty() + } + // org.json hands back JSONObject.NULL rather than null for absent fields, + // and hashing its text would invent an id out of nothing. + if (text.isEmpty() || text.equals("null", ignoreCase = true)) { + return 0 + } + text.toLongOrNull()?.let { value -> + if (value >= 0L) { + return (value and 0x7FFFFFFFL).toInt() + } + } + var hash = 17 + for (char in text) { + hash = (hash * 31 + char.code) and 0x7fffffff + } + return hash +} diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt index d4e0a70..4e63c58 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt @@ -139,15 +139,7 @@ private fun JSONArray?.objects(): List = private fun integer(raw: Any?): Int? = if (raw is Number) raw.toInt() else raw?.toString()?.trim()?.toIntOrNull() -internal fun stopId(raw: Any?): Int { - if (raw is Number) return raw.toInt() - val value = raw?.takeUnless { it == JSONObject.NULL }?.toString()?.trim().orEmpty() - if (value.isEmpty()) return 0 - value.toIntOrNull()?.let { return it } - var hash = 17 - for (char in value) hash = (hash * 31 + char.code) and 0x7fffffff - return hash -} +internal fun stopId(raw: Any?): Int = foldStopId(raw) private fun text(json: JSONObject, key: String): String? = json.opt(key) ?.takeUnless { it == JSONObject.NULL }?.toString()?.trim() diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt new file mode 100644 index 0000000..6fb8275 --- /dev/null +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -0,0 +1,721 @@ +package tw.avianjay.taiwanbus.flutter + +import kotlin.math.abs + +/** + * One user position fix. Deliberately free of android.location types so the + * detection logic can be replayed against recorded feeds in plain JVM tests. + */ +internal data class RideGeoPoint( + val latitude: Double, + val longitude: Double, + val timestampMs: Long, + val accuracyMeters: Float = 0f, + /** Doppler speed from the GPS chip, when the fix has one. */ + val speedMetersPerSecond: Float? = null, +) + +/** + * One vehicle position from `/realtime/buses`. + * + * [gpsTimeMs] is the vehicle's own clock and is what gets compared against the + * user's track. It reaches the app 20-40 s late at the median and up to ~200 s + * late in the tail, so "is this vehicle still reporting" is judged by + * [receivedAtMs] instead. + */ +internal data class RideBusReport( + val vehicleId: String, + val pathId: Int?, + val latitude: Double, + val longitude: Double, + val gpsTimeMs: Long, + val receivedAtMs: Long, +) + +internal enum class BoardingEvidence { + NONE, + + /** The user covered the same stretch of route as a live vehicle, at the same time. */ + BUS_CO_MOVEMENT, + + /** The user's own track left the boarding stop along the route at vehicle pace. */ + SELF_PROGRESS, + + /** The user tapped "我已上車". */ + USER_CONFIRMED, +} + +internal data class RideSnapshot( + val hasBoarded: Boolean = false, + val evidence: BoardingEvidence = BoardingEvidence.NONE, + val trackedVehicleId: String? = null, + val hasUsableLocation: Boolean = false, + val boardingStopIndex: Int = -1, + val boardingStopDistanceMeters: Double? = null, + /** Last stop reached by the rider (their own fix, else the tracked vehicle); -1 when unknown. */ + val progressStopIndex: Int = -1, + val nextBusVehicleId: String? = null, + val busStopsUntilBoarding: Int? = null, + /** Identifies the pending "did you get on?" question; 0 when there is none. */ + val boardingCheckId: Long = 0L, + val boardingPromptDue: Boolean = false, + val remainingStops: Int? = null, + val passedDestinationByStops: Int? = null, + val destinationDistanceMeters: Double? = null, +) + +/** + * Decides whether the user is riding the monitored route. + * + * Three layers, strongest evidence first: + * + * A. **Co-movement.** For every live vehicle, look up where the user was at the + * instants that vehicle reported its position. Riding together keeps the gap + * between the two roughly constant while both advance. This needs no plate + * in the ETA feed (雙北 ETAs carry none; `/realtime/buses` always does). + * + * B. **Self progress.** The user's own track leaving the boarding stop along the + * route at vehicle pace. Covers a boarded vehicle with no position feed. + * + * C. **The user's answer.** Once a bus has left the boarding stop and nothing + * shows the user stayed behind, ask. Works with no location at all. + * + * Everything is measured in metres along the stop sequence. A 2-D proximity + * test would be dominated by cross-track error: against the stop-chord line, + * bus fixes sit 8-16 m off at the median but 65-295 m at p90. + */ +internal class TripRideDetector( + stops: List, + private val pathId: Int, + boardingStopId: Int?, + destinationStopId: Int?, +) { + private val stopIds = stops.map { it.stopId } + private val line = RideRouteLine(stops) + + private val userTrack = ArrayDeque() + private val busTracks = LinkedHashMap>() + private val vehiclesSeenBeforeBoardingStop = HashSet() + + private val explicitBoardingIndex = indexOfStop(boardingStopId) + private var anchoredBoardingIndex = -1 + private var destinationIndex = indexOfStop(destinationStopId) + + private var userAlongHint: Double? = null + private var boarded = false + private var evidence = BoardingEvidence.NONE + private var trackedVehicleId: String? = null + private var autoDetectionSuppressed = false + + private var etaArrivingAtMs = 0L + private var pendingCheckSinceMs = 0L + private var pendingCheckVehicleId: String? = null + private var pendingCheckAnchor: UserSample? = null + + private val boardingIndex: Int + get() { + val index = if (explicitBoardingIndex >= 0) explicitBoardingIndex else anchoredBoardingIndex + return if (index >= 0 && destinationIndex >= 0) index.coerceAtMost(destinationIndex) else index + } + + /** Choosing a destination mid-ride must not disturb what is already known about the ride. */ + fun updateDestination(destinationStopId: Int?) { + destinationIndex = indexOfStop(destinationStopId) + } + + /** Feeds one user fix. Returns false when the fix was rejected as unusable. */ + fun onUserLocation(point: RideGeoPoint): Boolean { + if (point.accuracyMeters > MAX_USABLE_ACCURACY_METERS) { + return false + } + val previous = userTrack.lastOrNull() + if (previous != null) { + val elapsedSeconds = (point.timestampMs - previous.timestampMs) / 1000.0 + if (elapsedSeconds <= 0.0) { + // The same fix delivered again, or a clock step backwards. + return false + } + val stepMeters = line.metersBetween( + previous.latitude, + previous.longitude, + point.latitude, + point.longitude, + ) + // Faster than any bus: a GPS jump, not travel. Keeping it would + // corrupt every window that spans it. + if (stepMeters / elapsedSeconds > MAX_PLAUSIBLE_SPEED_MPS) { + return false + } + } + val projection = line.project(point.latitude, point.longitude, userAlongHint) + userAlongHint = projection.alongMeters + userTrack += UserSample( + latitude = point.latitude, + longitude = point.longitude, + alongMeters = projection.alongMeters, + timestampMs = point.timestampMs, + accuracyMeters = point.accuracyMeters, + speedMetersPerSecond = point.speedMetersPerSecond, + ) + while (userTrack.size > MAX_USER_SAMPLES) { + userTrack.removeFirst() + } + if (anchoredBoardingIndex < 0) { + // Used only when no boarding stop was chosen: the trip starts + // wherever the user actually is. + anchoredBoardingIndex = line.nearestStopIndex(projection.alongMeters) + } + return true + } + + fun onBusReports(reports: List) { + reports.forEach { report -> + if (report.vehicleId.isBlank() || report.gpsTimeMs <= 0L) { + return@forEach + } + val track = busTracks.getOrPut(report.vehicleId) { ArrayDeque() } + val last = track.lastOrNull() + // Vehicles report every ~20 s but the feed is polled more often, so + // most polls repeat the previous fix. + if (last != null && report.gpsTimeMs <= last.gpsTimeMs) { + return@forEach + } + val projection = line.project(report.latitude, report.longitude, last?.alongMeters) + track += BusSample( + alongMeters = projection.alongMeters, + gpsTimeMs = report.gpsTimeMs, + receivedAtMs = report.receivedAtMs, + samePath = report.pathId == null || report.pathId == pathId, + // Parked and deadheading vehicles project onto the route from + // far away; only vehicles actually on it may announce arrivals. + onRoute = projection.crossTrackMeters <= maxOf( + ON_ROUTE_MIN_CROSS_TRACK_METERS, + ON_ROUTE_SEGMENT_SHARE * line.segmentLengthMeters(projection.segmentIndex), + ), + ) + while (track.size > MAX_BUS_SAMPLES) { + track.removeFirst() + } + } + } + + /** + * Feeds the boarding stop's ETA. An estimate that reaches zero and then + * rolls over to the next bus means a vehicle has left, which is the only + * departure signal available when that vehicle has no position feed. + */ + fun onBoardingStopEta(etaSeconds: Int?, arriving: Boolean, nowMs: Long) { + if (arriving || (etaSeconds != null && etaSeconds <= ETA_ARRIVING_SECONDS)) { + etaArrivingAtMs = nowMs + return + } + // Once the estimate reaches zero it either jumps to the next bus or + // disappears entirely; both mean the vehicle that was arriving has gone. + val rolledOver = etaArrivingAtMs != 0L && + nowMs - etaArrivingAtMs <= ETA_ROLLOVER_WINDOW_MS && + (etaSeconds == null || etaSeconds >= ETA_ROLLOVER_SECONDS) + if (rolledOver) { + etaArrivingAtMs = 0L + registerDeparture(vehicleId = null, nowMs = nowMs) + } + } + + fun confirmBoardedByUser(nowMs: Long) { + boarded = true + evidence = BoardingEvidence.USER_CONFIRMED + autoDetectionSuppressed = false + trackedVehicleId = pendingCheckVehicleId ?: vehicleJustPastBoardingStop(nowMs) + clearPendingCheck() + } + + /** + * The user says they are not on the bus. Automatic detection stays off for + * the rest of the trip: whatever raised the false positive is still in the + * feed, so re-deriving it a minute later would only contradict the user + * again. They can still confirm the ride by hand. + */ + fun declineBoarding() { + boarded = false + evidence = BoardingEvidence.NONE + trackedVehicleId = null + autoDetectionSuppressed = true + clearPendingCheck() + } + + fun snapshot(nowMs: Long): RideSnapshot { + prune(nowMs) + val latestUser = userTrack.lastOrNull() + val usableUser = latestUser?.takeIf { nowMs - it.timestampMs <= LOCATION_STALE_MS } + val boardingStop = boardingIndex + val boardingAlong = if (boardingStop >= 0) line.alongOfStop(boardingStop) else null + val vehicles = liveVehicles(nowMs) + + if (boardingAlong != null) { + trackDepartures(vehicles, boardingAlong, nowMs) + } + if (!boarded && !autoDetectionSuppressed) { + val coMovingVehicleId = detectCoMovement(nowMs) + if (coMovingVehicleId != null) { + markBoarded(BoardingEvidence.BUS_CO_MOVEMENT, coMovingVehicleId) + } else if (boardingAlong != null && usableUser != null && + detectSelfProgress(nowMs, boardingAlong, usableUser) + ) { + markBoarded(BoardingEvidence.SELF_PROGRESS, vehicleId = null) + } + } + trackedVehicleId = resolveTrackedVehicle(vehicles, usableUser) + val trackedVehicle = vehicles.firstOrNull { it.vehicleId == trackedVehicleId } + + val nextBus = if (boardingAlong == null) { + null + } else { + vehicles + .filter { it.samePath && it.onRoute && it.alongMeters <= boardingAlong + AT_STOP_MARGIN_METERS } + .maxByOrNull { it.alongMeters } + } + + val progressAlong = usableUser?.alongMeters + ?: trackedVehicle?.alongMeters?.takeIf { boarded } + val progressStopIndex = progressAlong?.let(line::stopIndexAt) ?: -1 + val destinationStop = destinationIndex + val stopsToGo = if (destinationStop >= 0 && progressStopIndex >= 0) { + destinationStop - progressStopIndex + } else { + null + } + val destinationDistanceMeters = when { + destinationStop < 0 -> null + usableUser != null -> line.metersToStop(usableUser.latitude, usableUser.longitude, destinationStop) + progressAlong != null -> abs(line.alongOfStop(destinationStop) - progressAlong) + else -> null + } + // Resolved before the snapshot is assembled: it may retire the pending + // question, and the id reported alongside must agree with it. + val boardingPromptDue = resolveBoardingPromptDue(nowMs, usableUser, boardingStop) + + return RideSnapshot( + hasBoarded = boarded, + evidence = evidence, + trackedVehicleId = trackedVehicleId, + hasUsableLocation = usableUser != null, + boardingStopIndex = boardingStop, + boardingStopDistanceMeters = if (usableUser != null && boardingStop >= 0) { + line.metersToStop(usableUser.latitude, usableUser.longitude, boardingStop) + } else { + null + }, + progressStopIndex = progressStopIndex, + nextBusVehicleId = nextBus?.vehicleId, + busStopsUntilBoarding = nextBus?.let { + (boardingStop - line.stopIndexAt(it.alongMeters)).coerceAtLeast(0) + }, + boardingCheckId = pendingCheckSinceMs, + boardingPromptDue = boardingPromptDue, + remainingStops = stopsToGo?.coerceAtLeast(0), + passedDestinationByStops = stopsToGo?.let { -it }?.takeIf { it > 0 }, + destinationDistanceMeters = destinationDistanceMeters, + ) + } + + private fun markBoarded(evidence: BoardingEvidence, vehicleId: String?) { + boarded = true + this.evidence = evidence + trackedVehicleId = vehicleId + clearPendingCheck() + } + + /** + * Layer A. The gap between the user and a vehicle is sampled at each of the + * vehicle's reports, newest first, and must stay within + * [COMOVE_MAX_GAP_DRIFT_METERS] over a stretch in which both really moved. + * + * Standing at the stop while the bus drives off makes the gap grow by the + * bus's whole travel, and walking beside a bus stuck in traffic fails the + * speed floor, so neither counts. + */ + private fun detectCoMovement(nowMs: Long): String? { + if (userTrack.size < 2) { + return null + } + val pairs = ArrayList() + busTracks.forEach { (vehicleId, track) -> + pairs.clear() + track.forEach { report -> + if (report.receivedAtMs < nowMs - COMOVE_WINDOW_MS) { + return@forEach + } + val userAlong = userAlongAt(report.gpsTimeMs) ?: return@forEach + pairs += CoMovementPair(userAlong, report.alongMeters, report.gpsTimeMs) + } + if (isCoMovement(pairs)) { + return vehicleId + } + } + return null + } + + private fun isCoMovement(pairs: List): Boolean { + val newest = pairs.lastOrNull() ?: return false + var minGap = Double.MAX_VALUE + var maxGap = -Double.MAX_VALUE + for (start in pairs.indices.reversed()) { + val oldest = pairs[start] + val gap = oldest.userAlongMeters - oldest.busAlongMeters + minGap = minOf(minGap, gap) + maxGap = maxOf(maxGap, gap) + if (maxGap - minGap > COMOVE_MAX_GAP_DRIFT_METERS) { + // Reaching further back can only widen the drift. + return false + } + if (pairs.size - start < COMOVE_MIN_REPORTS) { + continue + } + val spanSeconds = (newest.gpsTimeMs - oldest.gpsTimeMs) / 1000.0 + if (spanSeconds < COMOVE_MIN_SPAN_SECONDS) { + continue + } + val busTravel = newest.busAlongMeters - oldest.busAlongMeters + val userTravel = newest.userAlongMeters - oldest.userAlongMeters + if (busTravel < COMOVE_MIN_TRAVEL_METERS || + userTravel < busTravel * COMOVE_MIN_USER_TRAVEL_SHARE || + userTravel / spanSeconds < COMOVE_MIN_USER_SPEED_MPS + ) { + continue + } + // A fixed offset between the two clocks shows up as a fixed number + // of metres at a given speed, so the allowance grows with speed. + val gapLimit = COMOVE_MAX_GAP_METERS + + COMOVE_CLOCK_ALLOWANCE_SECONDS * (busTravel / spanSeconds) + if (maxGap > gapLimit || minGap < -gapLimit) { + continue + } + if (!movedAtVehicleSpeed(oldest.gpsTimeMs, newest.gpsTimeMs)) { + continue + } + return true + } + return false + } + + /** + * Layer B. 500 m in 200 s is 2.5 m/s — above a brisk walk and above a bus + * that sat in traffic for the whole window — so a pedestrian following the + * same street from the stop is not mistaken for a passenger. + */ + private fun detectSelfProgress( + nowMs: Long, + boardingAlong: Double, + latestUser: UserSample, + ): Boolean { + val windowStartMs = nowMs - SELF_PROGRESS_WINDOW_MS + val origin = userTrack.firstOrNull { sample -> + sample.timestampMs >= windowStartMs && + sample.accuracyMeters <= SELF_PROGRESS_MAX_ACCURACY_METERS && + abs(sample.alongMeters - boardingAlong) <= BOARDING_ORIGIN_RADIUS_METERS + } ?: return false + val travel = latestUser.alongMeters - origin.alongMeters + val elapsedSeconds = (latestUser.timestampMs - origin.timestampMs) / 1000.0 + if (travel < SELF_PROGRESS_MIN_METERS || elapsedSeconds <= 0.0) { + return false + } + return travel / elapsedSeconds >= SELF_PROGRESS_MIN_SPEED_MPS && + movedAtVehicleSpeed(origin.timestampMs, latestUser.timestampMs) + } + + /** + * Doppler speed is immune to the position wander that makes a stationary + * phone look like it travelled, so when the fixes carry it, real movement + * must show up there too. + */ + private fun movedAtVehicleSpeed(fromMs: Long, toMs: Long): Boolean { + var withSpeed = 0 + var fast = 0 + userTrack.forEach { sample -> + if (sample.timestampMs < fromMs || sample.timestampMs > toMs) { + return@forEach + } + val speed = sample.speedMetersPerSecond ?: return@forEach + withSpeed++ + if (speed >= VEHICLE_SPEED_MPS) { + fast++ + } + } + return withSpeed < MIN_SPEED_SAMPLES || fast >= MIN_FAST_SAMPLES + } + + private fun trackDepartures( + vehicles: List, + boardingAlong: Double, + nowMs: Long, + ) { + vehicles.forEach { vehicle -> + if (!vehicle.samePath) { + return@forEach + } + val offset = vehicle.alongMeters - boardingAlong + if (offset <= AT_STOP_MARGIN_METERS) { + if (vehicle.onRoute) { + vehiclesSeenBeforeBoardingStop += vehicle.vehicleId + } + } else if (offset > LEFT_STOP_MARGIN_METERS && + vehiclesSeenBeforeBoardingStop.remove(vehicle.vehicleId) + ) { + registerDeparture(vehicle.vehicleId, nowMs) + } + } + } + + private fun registerDeparture(vehicleId: String?, nowMs: Long) { + if (boarded || autoDetectionSuppressed) { + return + } + if (pendingCheckSinceMs != 0L && nowMs - pendingCheckSinceMs < DEPARTURE_DEDUPE_MS) { + // The position feed and the ETA rollover both report the same bus. + pendingCheckVehicleId = pendingCheckVehicleId ?: vehicleId + return + } + val boardingStop = boardingIndex + val recentUser = userTrack.filter { nowMs - it.timestampMs <= DEPARTURE_USER_LOOKBACK_MS } + // With no fix at all there is no telling where the user is, and asking + // is the only tool left. + val userWasAtStop = recentUser.isEmpty() || recentUser.any { sample -> + boardingStop >= 0 && + line.metersToStop(sample.latitude, sample.longitude, boardingStop) <= + BOARDING_STOP_RADIUS_METERS + } + if (!userWasAtStop) { + return + } + pendingCheckSinceMs = nowMs + pendingCheckVehicleId = vehicleId + pendingCheckAnchor = recentUser.lastOrNull() + } + + private fun resolveBoardingPromptDue( + nowMs: Long, + usableUser: UserSample?, + boardingStop: Int, + ): Boolean { + if (boarded || autoDetectionSuppressed || pendingCheckSinceMs == 0L) { + return false + } + val ageMs = nowMs - pendingCheckSinceMs + if (ageMs > BOARDING_CHECK_MAX_AGE_MS) { + clearPendingCheck() + return false + } + if (boardingStop >= 0 && usableUser != null && + line.metersToStop(usableUser.latitude, usableUser.longitude, boardingStop) > + BOARDING_STOP_ABANDONED_RADIUS_METERS + ) { + // The user is nowhere near the stop any more, so this bus was never + // their ride; stop waiting for an answer. + clearPendingCheck() + return false + } + if (ageMs < BOARDING_PROMPT_DELAY_MS) { + return false + } + // A stationary phone is the one thing that reliably means "still + // waiting", so while the user has not moved there is nothing to ask. + return !stayedBehind(nowMs, usableUser) + } + + private fun stayedBehind(nowMs: Long, user: UserSample?): Boolean { + user ?: return false + if (nowMs - user.timestampMs > STAYED_BEHIND_MAX_FIX_AGE_MS || + user.accuracyMeters > STAYED_BEHIND_MAX_ACCURACY_METERS + ) { + return false + } + val anchor = pendingCheckAnchor ?: return false + return line.metersBetween(anchor.latitude, anchor.longitude, user.latitude, user.longitude) < + STAYED_BEHIND_MAX_MOVE_METERS + } + + private fun clearPendingCheck() { + pendingCheckSinceMs = 0L + pendingCheckVehicleId = null + pendingCheckAnchor = null + } + + /** + * Once on board, the vehicle to follow is the one the user is inside. Its + * reported position trails the user's own fix by the feed's delay, so it is + * expected a little behind and never far ahead. + */ + private fun resolveTrackedVehicle( + vehicles: List, + usableUser: UserSample?, + ): String? { + if (!boarded) { + return null + } + val locked = vehicles.firstOrNull { it.vehicleId == trackedVehicleId } + if (usableUser == null) { + return locked?.vehicleId + } + if (locked != null && + usableUser.alongMeters - locked.alongMeters in -TRACK_MAX_AHEAD_METERS..TRACK_KEEP_BEHIND_METERS + ) { + return locked.vehicleId + } + return vehicles + .filter { it.samePath } + .filter { usableUser.alongMeters - it.alongMeters in -TRACK_MAX_AHEAD_METERS..TRACK_PICK_BEHIND_METERS } + .minByOrNull { abs(usableUser.alongMeters - it.alongMeters) } + ?.vehicleId + } + + private fun vehicleJustPastBoardingStop(nowMs: Long): String? { + val boardingStop = boardingIndex + if (boardingStop < 0 || userTrack.isNotEmpty()) { + // With a location the nearest vehicle is resolved from the user. + return null + } + val boardingAlong = line.alongOfStop(boardingStop) + return liveVehicles(nowMs) + .filter { it.samePath && it.onRoute && it.alongMeters >= boardingAlong - AT_STOP_MARGIN_METERS } + .minByOrNull { it.alongMeters } + ?.vehicleId + } + + private fun liveVehicles(nowMs: Long): List { + return busTracks.entries.mapNotNull { (vehicleId, track) -> + val last = track.lastOrNull() ?: return@mapNotNull null + if (nowMs - last.receivedAtMs > BUS_REPORT_STALE_MS) { + return@mapNotNull null + } + LiveVehicle(vehicleId, last.alongMeters, last.samePath, last.onRoute) + } + } + + /** The user's along-route position at [timestampMs], linearly interpolated. */ + private fun userAlongAt(timestampMs: Long): Double? { + var previous: UserSample? = null + userTrack.forEach { sample -> + if (sample.timestampMs >= timestampMs) { + val before = previous + if (before == null) { + return sample.alongMeters.takeIf { + sample.timestampMs - timestampMs <= INTERPOLATION_TOLERANCE_MS + } + } + val spanMs = sample.timestampMs - before.timestampMs + if (spanMs > MAX_INTERPOLATION_SPAN_MS) { + // No fixes for too long to guess where the user was. + return null + } + val fraction = (timestampMs - before.timestampMs).toDouble() / spanMs + return before.alongMeters + fraction * (sample.alongMeters - before.alongMeters) + } + previous = sample + } + return previous?.alongMeters?.takeIf { + timestampMs - (previous?.timestampMs ?: 0L) <= INTERPOLATION_TOLERANCE_MS + } + } + + private fun prune(nowMs: Long) { + while (userTrack.isNotEmpty() && nowMs - userTrack.first().timestampMs > USER_TRACK_MAX_AGE_MS) { + userTrack.removeFirst() + } + busTracks.values.forEach { track -> + while (track.isNotEmpty() && nowMs - track.first().receivedAtMs > BUS_TRACK_MAX_AGE_MS) { + track.removeFirst() + } + } + busTracks.entries.removeAll { it.value.isEmpty() } + } + + private fun indexOfStop(stopId: Int?): Int { + return if (stopId == null) -1 else stopIds.indexOf(stopId) + } + + private data class UserSample( + val latitude: Double, + val longitude: Double, + val alongMeters: Double, + val timestampMs: Long, + val accuracyMeters: Float, + val speedMetersPerSecond: Float?, + ) + + private data class BusSample( + val alongMeters: Double, + val gpsTimeMs: Long, + val receivedAtMs: Long, + val samePath: Boolean, + val onRoute: Boolean, + ) + + private data class LiveVehicle( + val vehicleId: String, + val alongMeters: Double, + val samePath: Boolean, + val onRoute: Boolean, + ) + + private data class CoMovementPair( + val userAlongMeters: Double, + val busAlongMeters: Double, + val gpsTimeMs: Long, + ) + + private companion object { + const val MAX_USER_SAMPLES = 240 + const val MAX_BUS_SAMPLES = 60 + const val USER_TRACK_MAX_AGE_MS = 400_000L + const val BUS_TRACK_MAX_AGE_MS = 360_000L + + const val MAX_USABLE_ACCURACY_METERS = 100f + const val LOCATION_STALE_MS = 90_000L + const val MAX_PLAUSIBLE_SPEED_MPS = 55.0 + const val INTERPOLATION_TOLERANCE_MS = 6_000L + const val MAX_INTERPOLATION_SPAN_MS = 30_000L + + const val COMOVE_WINDOW_MS = 180_000L + const val COMOVE_MIN_REPORTS = 3 + const val COMOVE_MIN_SPAN_SECONDS = 30.0 + const val COMOVE_MIN_TRAVEL_METERS = 150.0 + const val COMOVE_MIN_USER_TRAVEL_SHARE = 0.7 + const val COMOVE_MIN_USER_SPEED_MPS = 2.2 + const val COMOVE_MAX_GAP_DRIFT_METERS = 110.0 + const val COMOVE_MAX_GAP_METERS = 200.0 + const val COMOVE_CLOCK_ALLOWANCE_SECONDS = 12.0 + + const val SELF_PROGRESS_WINDOW_MS = 200_000L + const val SELF_PROGRESS_MIN_METERS = 500.0 + const val SELF_PROGRESS_MIN_SPEED_MPS = 2.5 + const val SELF_PROGRESS_MAX_ACCURACY_METERS = 60f + const val BOARDING_ORIGIN_RADIUS_METERS = 250.0 + + const val VEHICLE_SPEED_MPS = 3f + const val MIN_SPEED_SAMPLES = 3 + const val MIN_FAST_SAMPLES = 2 + + const val BUS_REPORT_STALE_MS = 180_000L + const val ON_ROUTE_MIN_CROSS_TRACK_METERS = 150.0 + const val ON_ROUTE_SEGMENT_SHARE = 0.6 + const val AT_STOP_MARGIN_METERS = 40.0 + const val LEFT_STOP_MARGIN_METERS = 120.0 + + const val ETA_ARRIVING_SECONDS = 30 + const val ETA_ROLLOVER_SECONDS = 240 + const val ETA_ROLLOVER_WINDOW_MS = 150_000L + + const val DEPARTURE_DEDUPE_MS = 90_000L + const val DEPARTURE_USER_LOOKBACK_MS = 150_000L + const val BOARDING_STOP_RADIUS_METERS = 300.0 + const val BOARDING_STOP_ABANDONED_RADIUS_METERS = 400.0 + const val BOARDING_PROMPT_DELAY_MS = 40_000L + const val BOARDING_CHECK_MAX_AGE_MS = 300_000L + const val STAYED_BEHIND_MAX_FIX_AGE_MS = 30_000L + const val STAYED_BEHIND_MAX_ACCURACY_METERS = 50f + const val STAYED_BEHIND_MAX_MOVE_METERS = 60.0 + + const val TRACK_MAX_AHEAD_METERS = 250.0 + const val TRACK_KEEP_BEHIND_METERS = 2_500.0 + const val TRACK_PICK_BEHIND_METERS = 1_500.0 + } +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index 20b3dbc..3518007 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -150,7 +150,7 @@ class RouteTripMonitorServiceTest { } @Test - fun addingDestinationAfterBoardingKeepsRideAndTrackedVehicle() { + fun addingDestinationAfterBoardingKeepsTheRide() { val initialSession = sessionPayload() + mapOf( "routeId" to "TXG1", "boardingStopId" to 1, @@ -173,18 +173,20 @@ class RouteTripMonitorServiceTest { ), ) service.onStartCommand(startIntent(initialSession), 0, 1) - ReflectionHelpers.setField(service, "rideConfirmed", true) - ReflectionHelpers.setField(service, "trackedBusId", "KKA-1234") + // The user confirms the ride while waiting, exactly as the notification + // action does. + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + assertTrue(ride(service).hasBoarded) ReflectionHelpers.setField(service, "destinationAlertStage", 2) val destinationSession = initialSession + mapOf( "destinationStopId" to 2, "destinationStopName" to "下車站", ) - service.onStartCommand(startIntent(destinationSession), 0, 2) + service.onStartCommand(startIntent(destinationSession), 0, 3) - assertTrue(ReflectionHelpers.getField(service, "rideConfirmed")) - assertEquals("KKA-1234", ReflectionHelpers.getField(service, "trackedBusId")) + assertTrue("choosing a destination must not drop the ride", ride(service).hasBoarded) + assertEquals(BoardingEvidence.USER_CONFIRMED, ride(service).evidence) assertEquals(0, ReflectionHelpers.getField(service, "destinationAlertStage")) } @@ -200,8 +202,6 @@ class RouteTripMonitorServiceTest { )), ) service.onStartCommand(startIntent(payload(600, emptyList(), "MAIN-001")), 0, 1) - ReflectionHelpers.setField(service, "activeBoardingVehicleId", "MAIN-001") - ReflectionHelpers.setField(service, "boardingAlertSent", true) service.onStartCommand(startIntent(payload(60, listOf("TXG1", "TXG2"), "FAMILY-001")), 0, 2) val session = ReflectionHelpers.getField(service, "session") @@ -210,21 +210,144 @@ class RouteTripMonitorServiceTest { val live = ReflectionHelpers.getField>(service, "cachedLiveStops").getValue(1) assertEquals(60, live.seconds) assertEquals(listOf("FAMILY-001"), live.vehicleIds) - assertNull(ReflectionHelpers.getField(service, "activeBoardingVehicleId")) - assertFalse(ReflectionHelpers.getField(service, "boardingAlertSent")) + // The family arriving re-seeds the trip, so no ride survives from the + // preliminary primary-route response. + assertFalse(ride(service).hasBoarded) } @Test fun familyChangesDoNotResetConfirmedRide() { val initial = sessionPayload() + mapOf("routeId" to "TXG1") service.onStartCommand(startIntent(initial), 0, 1) - ReflectionHelpers.setField(service, "rideConfirmed", true) - ReflectionHelpers.setField(service, "trackedBusId", "FAMILY-001") - service.onStartCommand(startIntent(initial + mapOf("familyRouteIds" to listOf("TXG1", "TXG2"))), 0, 2) - assertTrue(ReflectionHelpers.getField(service, "rideConfirmed")) - assertEquals("FAMILY-001", ReflectionHelpers.getField(service, "trackedBusId")) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + assertTrue(ride(service).hasBoarded) + + service.onStartCommand( + startIntent(initial + mapOf("familyRouteIds" to listOf("TXG1", "TXG2"))), + 0, + 3, + ) + + assertTrue("a confirmed ride must survive the family arriving", ride(service).hasBoarded) + assertEquals(BoardingEvidence.USER_CONFIRMED, ride(service).evidence) + } + + @Test + fun reportingNotBoardedClearsTheRide() { + service.onStartCommand(startIntent(), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + assertTrue(ride(service).hasBoarded) + + service.onStartCommand(command("NOT_BOARDED"), 0, 3) + + assertFalse(ride(service).hasBoarded) + assertEquals(BoardingEvidence.NONE, ride(service).evidence) + } + + @Test + fun switchingRouteRebuildsTheRideState() { + service.onStartCommand(startIntent(), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + assertTrue(ride(service).hasBoarded) + + service.onStartCommand( + startIntent(sessionPayload() + mapOf("routeKey" to 7, "routeName" to "7")), + 0, + 3, + ) + + assertFalse("a different route is a different trip", ride(service).hasBoarded) + assertFalse(ride(service).boardingPromptDue) + } + + @Test + fun keepingTheSameTripDoesNotRebuildTheRide() { + val session = sessionPayload() + mapOf("routeId" to "TXG1") + service.onStartCommand(startIntent(session), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + + // Every refresh the route screen sends carries the same identity, so a + // rebuild here would wipe the ride the user just confirmed. + service.onStartCommand(startIntent(session), 0, 3) + + assertTrue("an identical update must not restart the trip", ride(service).hasBoarded) + assertEquals(BoardingEvidence.USER_CONFIRMED, ride(service).evidence) } + @Test + fun theWakeLockDoesNotOutliveTheTrip() { + val lockOf = { + ReflectionHelpers.getField(service, "wakeLock") + } + + service.onStartCommand(startIntent(), 0, 1) + assertNotNull("tracking must hold a wake lock", lockOf()) + assertTrue("the lock must actually be held", lockOf()!!.isHeld) + + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 2) + + assertNull("a stopped trip must not keep the CPU awake", lockOf()) + } + + @Test + fun theBoardingStopAndItsLiveEtaMeetOnTheSameKey() { + // 高雄 stop ids are 64-bit, and the two sides of this join used to + // compute the key differently — the session wrapped the low 32 bits + // while the feed hashed the digits — so the live ETA of a 高雄 stop + // could never be found. Both now fold the raw id the same way. + // + // This id is a real one from the 高雄 feed, chosen because its low 32 + // bits are negative: an id that wraps to a positive number would agree + // with the fold by accident and hide the bug. + val rawStopId = "101111144601" + + val session = RouteTripMonitorService.parseSessionPayload( + mapOf( + "provider" to "khh", + "routeKey" to 100, + "routeId" to "KHH100", + "pathId" to 0, + "boardingStopId" to rawStopId.toLong(), + "stops" to listOf( + mapOf( + "stopId" to rawStopId.toLong(), + "stopName" to "測試站", + "sequence" to 1, + "lat" to 22.60612, + "lon" to 120.338401, + ), + ), + ), + ) + // The failure this guards against is a silent one: the folded id stops + // being positive, so the session drops the boarding stop entirely and + // the live ETA is never consulted. Say that, rather than let it surface + // as a bare NPE. + assertNotNull("the session must keep a boarding stop for a 64-bit stop id", session) + val boardingStopId = requireNotNull(session?.boardingStopId) { + "the boarding stop was dropped instead of folded to a positive key" + } + // The live ETA now arrives through the family fetch, so the join is + // checked against the same code the service runs. + val liveStops = TripMonitorFamilyRealtime { path -> + assertEquals("routes/KHH100/realtime", path) + org.json.JSONObject( + """{"paths":[{"pathid":0,"stops":[{"stopid":"$rawStopId","eta":120}]}]}""", + ) + }.fetch(session!!) + + assertTrue("the boarding stop must fold to a positive key", boardingStopId > 0) + assertEquals("the session's stop must keep its id", boardingStopId, session.stops.single().stopId) + assertNotNull( + "the live ETA must be reachable from the session's boarding stop", + liveStops[boardingStopId], + ) + assertEquals(120, liveStops[boardingStopId]!!.seconds) + } + + private fun ride(service: RouteTripMonitorService): RideSnapshot = + ReflectionHelpers.getField(service, "latestRide") + private fun assertStoppedWithoutNotification() { assertTrue(shadow.isStoppedBySelf) assertTrue(shadow.isForegroundStopped) diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodecTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodecTest.kt new file mode 100644 index 0000000..8099664 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodecTest.kt @@ -0,0 +1,85 @@ +package tw.avianjay.taiwanbus.flutter + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Stop ids are 64-bit in the feeds but every stop map in the app is keyed by an + * `Int`. Both sides of that key fold through [foldStopId]; if they ever + * disagree, the live ETA of a 高雄 stop can never be looked up. + */ +class StopIdCodecTest { + @Test + fun leavesIdsThatAlreadyFitAlone() { + // 台中 and 雙北 ids are small; folding them must be a no-op. + assertEquals(13567, foldStopId("13567")) + assertEquals(194687, foldStopId(194687)) + assertEquals(1001001781, foldStopId("1001001781")) + } + + @Test + fun foldsIdsTooLargeForAnInt() { + // 高雄's real ids are 11-12 digits. + assertEquals(1315880413, foldStopId("100100128221")) + assertEquals(1315910904, foldStopId("100100158712")) + } + + @Test + fun neverReturnsSomethingThePositiveFiltersWouldDrop() { + // 172 高雄 routes have ids whose low 32 bits are negative; a plain + // toInt() turns those into ids the `> 0` checks throw away. + val ids = listOf("101111144601", "100100128221", "100100158712", "99999999999") + ids.forEach { raw -> + assertTrue("$raw folded to a non-positive id", foldStopId(raw) > 0) + } + } + + @Test + fun foldingIsIdempotent() { + // A value that has already been folded can be folded again: the session + // is rebuilt from a payload that already carries folded ids. + val once = foldStopId("100100128221") + assertEquals(once, foldStopId(once)) + } + + @Test + fun bothSidesOfTheJoinAgree() { + // The exact bug: the session side truncated while the feed side hashed, + // and the two values could never be equal. + val fromFeed = foldStopId("100100128221") + val fromSession = foldStopId("100100128221") + assertEquals(fromFeed, fromSession) + } + + @Test + fun hashesIdsThatAreNotNumeric() { + // Some providers use ids like "101-11009". + val first = foldStopId("101-11009") + val second = foldStopId("101-11175") + assertTrue("non-numeric ids must still fold to positive keys", first > 0 && second > 0) + assertEquals("the same id must always fold the same way", first, foldStopId("101-11009")) + assertTrue("different ids must not collide", first != second) + } + + @Test + fun missingIdsFoldToZeroSoTheFiltersStillWork() { + assertEquals(0, foldStopId(null)) + assertEquals(0, foldStopId("")) + assertEquals(0, foldStopId(" ")) + // org.json hands back JSONObject.NULL for absent fields. + assertEquals(0, foldStopId(org.json.JSONObject.NULL)) + } + + @Test + fun realIdsDoNotCollide() { + // Verified against the backend's 114,903 distinct ids: masking collides + // on none of them. + val ids = listOf( + "13567", "12251", "194687", "1001001781", "100100128221", + "100100158712", "101111144601", "101111144651", "101111144681", + ) + val folded = ids.map(::foldStopId) + assertEquals("folding must be injective for real ids", ids.size, folded.toSet().size) + } +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt new file mode 100644 index 0000000..68ffa23 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt @@ -0,0 +1,402 @@ +package tw.avianjay.taiwanbus.flutter + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.float +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Replays recorded live feeds through [TripRideDetector]. + * + * Each fixture is a real capture of `/realtime/buses` and `/realtime` for one + * route: a synthetic passenger waits at a stop and then rides one of the + * recorded vehicles, while two negative controls are on the same street without + * being on the bus — waiting at the stop (with urban-canyon GPS noise), and + * walking the route. + */ +class TripRideDetectorReplayTest { + @Test + fun detectsRidingATaichungBus() { + val replay = Replay.load("ride_replay_TXG300_0") + val result = replay.detect(replay.riderFixes) + + assertTrue("expected the ride to be detected", result.detected) + assertTrue( + "detected ${(result.detectedAtMs - replay.boardedAtMs) / 1000}s after boarding", + result.detectedAtMs - replay.boardedAtMs <= 180_000L, + ) + } + + @Test + fun detectsRidingAKaohsiungBus() { + val replay = Replay.load("ride_replay_KHH100_0") + val result = replay.detect(replay.riderFixes) + + assertTrue("expected the ride to be detected", result.detected) + assertTrue( + "detected ${(result.detectedAtMs - replay.boardedAtMs) / 1000}s after boarding", + result.detectedAtMs - replay.boardedAtMs <= 180_000L, + ) + } + + @Test + fun detectsRidingATaipeiBusWhoseEtaFeedHasNoPlates() { + val replay = Replay.load("ride_replay_TPE157463_0") + val result = replay.detect(replay.riderFixes) + + assertTrue("expected the ride to be detected", result.detected) + assertTrue( + "detected ${(result.detectedAtMs - replay.boardedAtMs) / 1000}s after boarding", + result.detectedAtMs - replay.boardedAtMs <= 180_000L, + ) + } + + @Test + fun ridesTheRecordedVehicle() { + val replay = Replay.load("ride_replay_TXG300_0") + val result = replay.detect(replay.riderFixes) + + assertTrue(result.detected) + assertEquals(replay.plate, result.trackedVehicleId) + } + + @Test + fun doesNotDetectWaitingAtTheBoardingStop() { + val replay = Replay.load("ride_replay_TXG300_0") + val result = replay.detect(replay.waitingFixes) + + assertFalse("waiting at the stop must never count as boarding", result.detected) + } + + @Test + fun doesNotDetectWaitingAtTheBoardingStopInTaipei() { + val replay = Replay.load("ride_replay_TPE157463_0") + val result = replay.detect(replay.waitingFixes) + + assertFalse("waiting at the stop must never count as boarding", result.detected) + } + + @Test + fun doesNotDetectWalkingAlongTheRoute() { + val replay = Replay.load("ride_replay_TXG300_0") + val result = replay.detect(replay.walkerFixes) + + assertFalse("walking the same street must never count as boarding", result.detected) + } + + @Test + fun doesNotDetectWalkingAlongTheRouteInTaipei() { + val replay = Replay.load("ride_replay_TPE157463_0") + val result = replay.detect(replay.walkerFixes) + + assertFalse("walking the same street must never count as boarding", result.detected) + } + + @Test + fun asksTheUserAfterABusLeavesTheBoardingStop() { + val replay = Replay.load("ride_replay_TXG300_0") + // The user drifts around the stop, as a phone in a pocket does, and + // eventually wanders off down the street — so the app cannot conclude + // from stillness alone that they are still waiting. + val drifting = replay.waitingFixes.mapIndexed { index, fix -> + if (index < replay.waitingFixes.size / 2) fix else replay.walkerFixes[index] + } + var prompted = false + val result = replay.detect(drifting) { _, snapshot -> + if (snapshot.boardingPromptDue) { + prompted = true + } + } + + assertTrue("a passenger who never boarded should be asked", prompted) + assertFalse("walking away from the stop is not a ride", result.detected) + } + + @Test + fun doesNotAskWhileTheUserIsStillRiding() { + val replay = Replay.load("ride_replay_TXG300_0") + var askedWhileRiding = false + val result = replay.detect(replay.riderFixes) { _, snapshot -> + if (snapshot.hasBoarded && snapshot.boardingPromptDue) { + askedWhileRiding = true + } + } + + assertTrue("expected the ride to be detected", result.detected) + assertFalse("a confirmed ride must not be questioned", askedWhileRiding) + } + + @Test + fun asksEvenWhenTheBusPositionFeedIsEmpty() { + val replay = Replay.load("ride_replay_TPE157463_0") + var prompted = false + replay.detect(replay.waitingFixes, withBusReports = false) { _, snapshot -> + if (snapshot.boardingPromptDue) { + prompted = true + } + } + + assertTrue("the ETA feed alone must be able to raise the question", prompted) + } + + @Test + fun userConfirmationWinsOverEverything() { + val replay = Replay.load("ride_replay_TXG300_0") + val detector = replay.detector() + replay.pump(detector, withBusReports = true, withUserFixes = false) { _, _ -> } + + detector.confirmBoardedByUser(replay.lastFrameMs) + + val snapshot = detector.snapshot(replay.lastFrameMs) + assertTrue(snapshot.hasBoarded) + assertEquals(BoardingEvidence.USER_CONFIRMED, snapshot.evidence) + assertFalse(snapshot.boardingPromptDue) + } + + @Test + fun decliningABoardingKeepsTheRideCancelled() { + val replay = Replay.load("ride_replay_TXG300_0") + val detector = replay.detector() + var declined = false + replay.pump(detector, withUserFixes = true) { nowMs, snapshot -> + if (!declined && snapshot.hasBoarded) { + detector.declineBoarding() + declined = true + } + } + + assertTrue("expected a ride to be detected before declining it", declined) + val snapshot = detector.snapshot(replay.lastFrameMs) + assertFalse("declining must stick", snapshot.hasBoarded) + assertEquals(BoardingEvidence.NONE, snapshot.evidence) + } + + @Test + fun remainingStopsCountDownAndNeverGoNegative() { + val replay = Replay.load("ride_replay_TXG300_0") + var previous = Int.MAX_VALUE + var observed = false + replay.detect(replay.riderFixes) { _, snapshot -> + val remaining = snapshot.remainingStops ?: return@detect + observed = true + assertTrue("remainingStops grew: $previous -> $remaining", remaining <= previous) + assertTrue("remainingStops went negative: $remaining", remaining >= 0) + previous = remaining + } + + assertTrue("expected a destination to be tracked", observed) + } + + @Test + fun changingTheDestinationDoesNotDisturbTheRide() { + val replay = Replay.load("ride_replay_TXG300_0") + val detector = replay.detector() + var changed = false + replay.pump(detector, withUserFixes = true) { _, snapshot -> + if (!changed && snapshot.hasBoarded) { + detector.updateDestination(replay.destinationStopId - 1) + changed = true + } + } + + assertTrue(changed) + val snapshot = detector.snapshot(replay.lastFrameMs) + assertTrue("the ride must survive a new destination", snapshot.hasBoarded) + assertEquals(replay.plate, snapshot.trackedVehicleId) + } +} + +private class Replay( + private val stops: List, + private val pathId: Int, + val plate: String, + val boardedAtMs: Long, + val destinationStopId: Int, + private val boardingStopId: Int, + private val frames: List, + val riderFixes: List, + val waitingFixes: List, + val walkerFixes: List, +) { + /** One poll of the live feeds, at the moment the app would have made it. */ + class Frame( + val nowMs: Long, + val reports: List, + val boardingEta: EtaReading?, + ) + + class EtaReading(val etaSeconds: Int?, val arriving: Boolean) + + class DetectionResult( + val detected: Boolean, + val detectedAtMs: Long, + val trackedVehicleId: String?, + ) + + val lastFrameMs: Long get() = frames.last().nowMs + + fun detector(): TripRideDetector = TripRideDetector( + stops = stops, + pathId = pathId, + boardingStopId = boardingStopId, + destinationStopId = destinationStopId, + ) + + /** + * Feeds the recorded polls and [fixes] in time order, reporting every + * snapshot. The detector's own answer is what decides when a ride has been + * detected, so this never reads state the app could not see. + */ + fun detect( + fixes: List, + withBusReports: Boolean = true, + onSnapshot: (Long, RideSnapshot) -> Unit = { _, _ -> }, + ): DetectionResult { + val detector = detector() + var detectedAtMs = 0L + var trackedVehicleId: String? = null + var fixIndex = 0 + pump(detector, withBusReports = withBusReports, withUserFixes = false) { nowMs, _ -> + while (fixIndex < fixes.size && fixes[fixIndex].timestampMs <= nowMs) { + detector.onUserLocation(fixes[fixIndex]) + fixIndex++ + } + val snapshot = detector.snapshot(nowMs) + if (detectedAtMs == 0L && snapshot.hasBoarded) { + detectedAtMs = nowMs + trackedVehicleId = snapshot.trackedVehicleId + } + onSnapshot(nowMs, snapshot) + } + return DetectionResult(detectedAtMs != 0L, detectedAtMs, trackedVehicleId) + } + + /** Walks the recorded polls without feeding any user fixes. */ + fun pump( + detector: TripRideDetector, + withBusReports: Boolean = true, + withUserFixes: Boolean = false, + onSnapshot: (Long, RideSnapshot) -> Unit, + ) { + frames.forEach { frame -> + if (withBusReports && frame.reports.isNotEmpty()) { + detector.onBusReports(frame.reports) + } + frame.boardingEta?.let { + detector.onBoardingStopEta(it.etaSeconds, it.arriving, frame.nowMs) + } + if (withUserFixes) { + detector.onUserLocation(nearestFix(riderFixes, frame.nowMs)) + } + onSnapshot(frame.nowMs, detector.snapshot(frame.nowMs)) + } + } + + private fun nearestFix(fixes: List, nowMs: Long): RideGeoPoint { + var low = 0 + var high = fixes.size - 1 + while (low < high) { + val mid = (low + high) / 2 + if (fixes[mid].timestampMs < nowMs) low = mid + 1 else high = mid + } + return fixes[low] + } + + companion object { + // org.json is only a stub on the plain JVM test classpath, so fixtures + // are read with kotlinx.serialization instead. + fun load(name: String): Replay { + val stream = Replay::class.java.classLoader?.getResourceAsStream("$name.json") + val text = requireNotNull(stream) { "missing fixture $name.json" } + .bufferedReader(Charsets.UTF_8) + .use { it.readText() } + val root = Json.parseToJsonElement(text).jsonObject + + val stops = root.getArray("stops").map { element -> + val stop = element.jsonObject + TrackingStop( + // Folded exactly as the service folds it, from the raw feed + // value. A fixture that stored pre-folded ids could not tell + // a correct fold from a broken one. + stopId = foldStopId(stop.getValue("stopIdRaw").jsonPrimitive.content), + stopName = "stop-${stop.getInt("sequence")}", + sequence = stop.getInt("sequence"), + lat = stop.getValue("lat").jsonPrimitive.double, + lon = stop.getValue("lon").jsonPrimitive.double, + ) + } + + val reportsByFrame = root.getArray("busReports").groupBy { it.jsonObject.getLong("rx") } + val etaByFrame = root.getArray("boardingEta").associateBy { it.jsonObject.getLong("t") } + val frames = (reportsByFrame.keys + etaByFrame.keys).sorted().map { nowMs -> + Frame( + nowMs = nowMs, + reports = reportsByFrame[nowMs].orEmpty().map { element -> + val report = element.jsonObject + RideBusReport( + vehicleId = report.getValue("id").jsonPrimitive.content, + pathId = report["dir"]?.jsonPrimitive?.content?.toIntOrNull(), + latitude = report.getValue("lat").jsonPrimitive.double, + longitude = report.getValue("lon").jsonPrimitive.double, + gpsTimeMs = report.getValue("t").jsonPrimitive.long, + receivedAtMs = nowMs, + ) + }, + boardingEta = etaByFrame[nowMs]?.jsonObject?.let { eta -> + EtaReading( + etaSeconds = eta["eta"]?.jsonPrimitive?.content?.toIntOrNull(), + arriving = eta["arriving"]?.jsonPrimitive?.boolean ?: false, + ) + }, + ) + } + + return Replay( + stops = stops, + pathId = root.getInt("pathId"), + plate = root.getValue("plate").jsonPrimitive.content, + boardedAtMs = root.getValue("boardedAtMs").jsonPrimitive.long, + destinationStopId = foldStopId( + root.getValue("destinationStopIdRaw").jsonPrimitive.content, + ), + boardingStopId = foldStopId( + root.getValue("boardingStopIdRaw").jsonPrimitive.content, + ), + frames = frames, + riderFixes = root.fixes("riderFixes"), + waitingFixes = root.fixes("waitingFixes"), + walkerFixes = root.fixes("walkerFixes"), + ) + } + + private fun JsonObject.getArray(key: String) = getValue(key).jsonArray + + private fun JsonObject.getInt(key: String) = getValue(key).jsonPrimitive.int + + private fun JsonObject.getLong(key: String) = getValue(key).jsonPrimitive.long + + private fun JsonObject.fixes(key: String): List { + return getArray(key).map { element -> + val fix = element.jsonObject + RideGeoPoint( + latitude = fix.getValue("lat").jsonPrimitive.double, + longitude = fix.getValue("lon").jsonPrimitive.double, + timestampMs = fix.getValue("t").jsonPrimitive.long, + accuracyMeters = fix["acc"]?.jsonPrimitive?.float ?: 0f, + speedMetersPerSecond = fix["spd"]?.jsonPrimitive?.float, + ) + } + } + } +} diff --git a/android/app/src/test/resources/ride_replay_KHH100_0.json b/android/app/src/test/resources/ride_replay_KHH100_0.json new file mode 100644 index 0000000..d9e5aca --- /dev/null +++ b/android/app/src/test/resources/ride_replay_KHH100_0.json @@ -0,0 +1 @@ +{"route":"KHH100","pathId":0,"plate":"051-V3","boardedAtMs":1794376372610,"boardingStopIdRaw":"100100128221","destinationStopIdRaw":"1001001521","stops":[{"stopIdRaw":"1001001781","sequence":1,"lat":22.60612,"lon":120.338401},{"stopIdRaw":"1001003081","sequence":2,"lat":22.605755,"lon":120.334116},{"stopIdRaw":"1001008311","sequence":3,"lat":22.607333,"lon":120.333533},{"stopIdRaw":"1001008321","sequence":4,"lat":22.609866,"lon":120.33315},{"stopIdRaw":"1001008331","sequence":5,"lat":22.612866,"lon":120.332716},{"stopIdRaw":"100100128221","sequence":6,"lat":22.614283,"lon":120.332533},{"stopIdRaw":"1001008371","sequence":7,"lat":22.617133,"lon":120.33225},{"stopIdRaw":"1001008401","sequence":8,"lat":22.619716,"lon":120.331966},{"stopIdRaw":"1001008411","sequence":9,"lat":22.621489,"lon":120.331691},{"stopIdRaw":"10010095351","sequence":10,"lat":22.622119,"lon":120.329212},{"stopIdRaw":"1001001521","sequence":11,"lat":22.621192,"lon":120.326523},{"stopIdRaw":"100100521","sequence":12,"lat":22.620276,"lon":120.323801},{"stopIdRaw":"10010095371","sequence":13,"lat":22.619572,"lon":120.321493},{"stopIdRaw":"10010095391","sequence":14,"lat":22.619,"lon":120.319791},{"stopIdRaw":"10010095401","sequence":15,"lat":22.618223,"lon":120.317459},{"stopIdRaw":"10010095411","sequence":16,"lat":22.617162,"lon":120.313862},{"stopIdRaw":"10010095541","sequence":17,"lat":22.616591,"lon":120.312333},{"stopIdRaw":"10010095551","sequence":18,"lat":22.616015,"lon":120.310558},{"stopIdRaw":"10010095561","sequence":19,"lat":22.615457,"lon":120.308804},{"stopIdRaw":"10010095771","sequence":20,"lat":22.614469,"lon":120.306097},{"stopIdRaw":"1001007571","sequence":21,"lat":22.613541,"lon":120.303198},{"stopIdRaw":"1001009071","sequence":22,"lat":22.61283,"lon":120.300958},{"stopIdRaw":"1001007661","sequence":23,"lat":22.61238,"lon":120.299629},{"stopIdRaw":"1001008591","sequence":24,"lat":22.613843,"lon":120.297885},{"stopIdRaw":"10010095781","sequence":25,"lat":22.616931,"lon":120.296764},{"stopIdRaw":"100100801","sequence":26,"lat":22.619297,"lon":120.295867},{"stopIdRaw":"10010025951","sequence":27,"lat":22.621196,"lon":120.295963},{"stopIdRaw":"100100122871","sequence":28,"lat":22.621859,"lon":120.29761},{"stopIdRaw":"10010095791","sequence":29,"lat":22.622617,"lon":120.299883},{"stopIdRaw":"100100158451","sequence":30,"lat":22.623627,"lon":120.30297},{"stopIdRaw":"100100158491","sequence":31,"lat":22.626727,"lon":120.304377},{"stopIdRaw":"10010095931","sequence":32,"lat":22.630165,"lon":120.301918},{"stopIdRaw":"100100158501","sequence":33,"lat":22.634002,"lon":120.302372},{"stopIdRaw":"100100158511","sequence":34,"lat":22.639645,"lon":120.303662},{"stopIdRaw":"100100158521","sequence":35,"lat":22.64565,"lon":120.301402},{"stopIdRaw":"100100158531","sequence":36,"lat":22.646418,"lon":120.299898},{"stopIdRaw":"10010091321","sequence":37,"lat":22.651493,"lon":120.302197},{"stopIdRaw":"100100158541","sequence":38,"lat":22.654081,"lon":120.303772},{"stopIdRaw":"100100158551","sequence":39,"lat":22.656977,"lon":120.303235},{"stopIdRaw":"100100158581","sequence":40,"lat":22.660755,"lon":120.303238},{"stopIdRaw":"100100158591","sequence":41,"lat":22.66423,"lon":120.30333},{"stopIdRaw":"100100158601","sequence":42,"lat":22.666229,"lon":120.303453},{"stopIdRaw":"100100158631","sequence":43,"lat":22.668385,"lon":120.303541},{"stopIdRaw":"100100158641","sequence":44,"lat":22.670703,"lon":120.301788}],"busReports":[{"id":"038-V3","dir":1,"lat":22.617069,"lon":120.313983,"t":1794376116000,"rx":1794376162068},{"id":"051-V3","dir":0,"lat":22.605685,"lon":120.333899,"t":1794376112000,"rx":1794376162068},{"id":"056-V3","dir":0,"lat":22.61973,"lon":120.322109,"t":1794376115000,"rx":1794376162068},{"id":"059-V3","dir":0,"lat":22.620523,"lon":120.29523,"t":1794376114000,"rx":1794376162068},{"id":"063-V3","dir":0,"lat":22.671093,"lon":120.303856,"t":1794376114000,"rx":1794376162068},{"id":"067-V3","dir":0,"lat":22.627553,"lon":120.302295,"t":1794376116000,"rx":1794376162068},{"id":"068-V3","dir":1,"lat":22.645581,"lon":120.300651,"t":1794376113000,"rx":1794376162068},{"id":"111-V3","dir":0,"lat":22.606338,"lon":120.33839,"t":1794376116000,"rx":1794376162068},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376114000,"rx":1794376162068},{"id":"123-V3","dir":1,"lat":22.654108,"lon":120.303543,"t":1794376125000,"rx":1794376162068},{"id":"185-FT","dir":1,"lat":22.616431,"lon":120.332241,"t":1794376129000,"rx":1794376162068},{"id":"038-V3","dir":1,"lat":22.617373,"lon":120.314958,"t":1794376156000,"rx":1794376182859},{"id":"051-V3","dir":0,"lat":22.605765,"lon":120.333618,"t":1794376152000,"rx":1794376182859},{"id":"056-V3","dir":0,"lat":22.618974,"lon":120.31981,"t":1794376155000,"rx":1794376182859},{"id":"059-V3","dir":0,"lat":22.621458,"lon":120.296393,"t":1794376154000,"rx":1794376182859},{"id":"063-V3","dir":0,"lat":22.672245,"lon":120.301426,"t":1794376154000,"rx":1794376182859},{"id":"067-V3","dir":0,"lat":22.627801,"lon":120.301609,"t":1794376156000,"rx":1794376182859},{"id":"068-V3","dir":1,"lat":22.645541,"lon":120.30141,"t":1794376153000,"rx":1794376182859},{"id":"111-V3","dir":0,"lat":22.606338,"lon":120.33839,"t":1794376156000,"rx":1794376182859},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376154000,"rx":1794376182859},{"id":"123-V3","dir":1,"lat":22.652446,"lon":120.303568,"t":1794376152000,"rx":1794376182859},{"id":"185-FT","dir":1,"lat":22.613478,"lon":120.332579,"t":1794376157000,"rx":1794376182859},{"id":"038-V3","dir":1,"lat":22.61755,"lon":120.315378,"t":1794376176000,"rx":1794376208998},{"id":"051-V3","dir":0,"lat":22.606766,"lon":120.333486,"t":1794376172000,"rx":1794376208998},{"id":"056-V3","dir":0,"lat":22.618475,"lon":120.318088,"t":1794376184000,"rx":1794376208998},{"id":"059-V3","dir":0,"lat":22.622088,"lon":120.29823,"t":1794376186000,"rx":1794376208998},{"id":"063-V3","dir":0,"lat":22.671366,"lon":120.300951,"t":1794376174000,"rx":1794376208998},{"id":"067-V3","dir":0,"lat":22.628879,"lon":120.301763,"t":1794376176000,"rx":1794376208998},{"id":"068-V3","dir":1,"lat":22.645411,"lon":120.30294,"t":1794376173000,"rx":1794376208998},{"id":"111-V3","dir":0,"lat":22.606338,"lon":120.33839,"t":1794376176000,"rx":1794376208998},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376174000,"rx":1794376208998},{"id":"123-V3","dir":1,"lat":22.652446,"lon":120.303568,"t":1794376172000,"rx":1794376208998},{"id":"185-FT","dir":1,"lat":22.61325,"lon":120.332608,"t":1794376182000,"rx":1794376208998},{"id":"038-V3","dir":1,"lat":22.61824,"lon":120.317451,"t":1794376216000,"rx":1794376233211},{"id":"051-V3","dir":0,"lat":22.608035,"lon":120.33334,"t":1794376213000,"rx":1794376233211},{"id":"056-V3","dir":0,"lat":22.61803,"lon":120.316646,"t":1794376218000,"rx":1794376233211},{"id":"059-V3","dir":0,"lat":22.622116,"lon":120.298285,"t":1794376214000,"rx":1794376233211},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376214000,"rx":1794376233211},{"id":"067-V3","dir":0,"lat":22.629539,"lon":120.301841,"t":1794376216000,"rx":1794376233211},{"id":"068-V3","dir":1,"lat":22.644514,"lon":120.302921,"t":1794376213000,"rx":1794376233211},{"id":"111-V3","dir":0,"lat":22.606338,"lon":120.33839,"t":1794376216000,"rx":1794376233211},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376214000,"rx":1794376233211},{"id":"123-V3","dir":1,"lat":22.652378,"lon":120.303558,"t":1794376212000,"rx":1794376233211},{"id":"185-FT","dir":1,"lat":22.61325,"lon":120.332608,"t":1794376202000,"rx":1794376233211},{"id":"038-V3","dir":1,"lat":22.61824,"lon":120.317451,"t":1794376236000,"rx":1794376278940},{"id":"051-V3","dir":0,"lat":22.610648,"lon":120.332988,"t":1794376243000,"rx":1794376278940},{"id":"056-V3","dir":0,"lat":22.617871,"lon":120.316159,"t":1794376235000,"rx":1794376278940},{"id":"059-V3","dir":0,"lat":22.622116,"lon":120.298285,"t":1794376234000,"rx":1794376278940},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376234000,"rx":1794376278940},{"id":"067-V3","dir":0,"lat":22.630346,"lon":120.301981,"t":1794376236000,"rx":1794376278940},{"id":"068-V3","dir":1,"lat":22.643113,"lon":120.302824,"t":1794376233000,"rx":1794376278940},{"id":"111-V3","dir":0,"lat":22.606338,"lon":120.33839,"t":1794376236000,"rx":1794376278940},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376234000,"rx":1794376278940},{"id":"123-V3","dir":1,"lat":22.651758,"lon":120.302764,"t":1794376241000,"rx":1794376278940},{"id":"185-FT","dir":1,"lat":22.611944,"lon":120.332731,"t":1794376248000,"rx":1794376278940},{"id":"038-V3","dir":1,"lat":22.618323,"lon":120.31776,"t":1794376276000,"rx":1794376301877},{"id":"051-V3","dir":0,"lat":22.612151,"lon":120.332776,"t":1794376272000,"rx":1794376301877},{"id":"056-V3","dir":0,"lat":22.617776,"lon":120.315915,"t":1794376275000,"rx":1794376301877},{"id":"059-V3","dir":0,"lat":22.622116,"lon":120.298285,"t":1794376274000,"rx":1794376301877},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376274000,"rx":1794376301877},{"id":"067-V3","dir":0,"lat":22.630524,"lon":120.301969,"t":1794376276000,"rx":1794376301877},{"id":"068-V3","dir":1,"lat":22.640606,"lon":120.301783,"t":1794376273000,"rx":1794376301877},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376276000,"rx":1794376301877},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376274000,"rx":1794376301877},{"id":"123-V3","dir":1,"lat":22.651074,"lon":120.300283,"t":1794376272000,"rx":1794376301877},{"id":"185-FT","dir":1,"lat":22.610523,"lon":120.332873,"t":1794376262000,"rx":1794376301877},{"id":"038-V3","dir":1,"lat":22.618438,"lon":120.318308,"t":1794376296000,"rx":1794376327152},{"id":"051-V3","dir":0,"lat":22.612151,"lon":120.332776,"t":1794376292000,"rx":1794376327152},{"id":"056-V3","dir":0,"lat":22.617321,"lon":120.314496,"t":1794376295000,"rx":1794376327152},{"id":"059-V3","dir":0,"lat":22.622415,"lon":120.299165,"t":1794376308000,"rx":1794376327152},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376294000,"rx":1794376327152},{"id":"067-V3","dir":0,"lat":22.630534,"lon":120.301969,"t":1794376296000,"rx":1794376327152},{"id":"068-V3","dir":1,"lat":22.640603,"lon":120.301784,"t":1794376293000,"rx":1794376327152},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376296000,"rx":1794376327152},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376294000,"rx":1794376327152},{"id":"123-V3","dir":1,"lat":22.650166,"lon":120.300161,"t":1794376292000,"rx":1794376327152},{"id":"185-FT","dir":1,"lat":22.60929,"lon":120.333031,"t":1794376302000,"rx":1794376327152},{"id":"038-V3","dir":1,"lat":22.619225,"lon":120.320779,"t":1794376337000,"rx":1794376351234},{"id":"051-V3","dir":0,"lat":22.612293,"lon":120.332743,"t":1794376332000,"rx":1794376351234},{"id":"056-V3","dir":0,"lat":22.616966,"lon":120.313474,"t":1794376335000,"rx":1794376351234},{"id":"059-V3","dir":0,"lat":22.623171,"lon":120.301339,"t":1794376334000,"rx":1794376351234},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376334000,"rx":1794376351234},{"id":"067-V3","dir":0,"lat":22.630534,"lon":120.301969,"t":1794376336000,"rx":1794376351234},{"id":"068-V3","dir":1,"lat":22.640603,"lon":120.301784,"t":1794376333000,"rx":1794376351234},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376336000,"rx":1794376351234},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376334000,"rx":1794376351234},{"id":"123-V3","dir":1,"lat":22.647991,"lon":120.299946,"t":1794376332000,"rx":1794376351234},{"id":"185-FT","dir":1,"lat":22.609116,"lon":120.333055,"t":1794376331000,"rx":1794376351234},{"id":"038-V3","dir":1,"lat":22.619946,"lon":120.323004,"t":1794376356000,"rx":1794376398309},{"id":"051-V3","dir":0,"lat":22.613546,"lon":120.332603,"t":1794376363000,"rx":1794376398309},{"id":"056-V3","dir":0,"lat":22.616613,"lon":120.312319,"t":1794376355000,"rx":1794376398309},{"id":"059-V3","dir":0,"lat":22.623658,"lon":120.302911,"t":1794376354000,"rx":1794376398309},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376354000,"rx":1794376398309},{"id":"067-V3","dir":0,"lat":22.630946,"lon":120.302019,"t":1794376356000,"rx":1794376398309},{"id":"068-V3","dir":1,"lat":22.640463,"lon":120.301786,"t":1794376353000,"rx":1794376398309},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376356000,"rx":1794376398309},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376354000,"rx":1794376398309},{"id":"123-V3","dir":1,"lat":22.647629,"lon":120.29994,"t":1794376364000,"rx":1794376398309},{"id":"185-FT","dir":1,"lat":22.607341,"lon":120.333264,"t":1794376362000,"rx":1794376398309},{"id":"038-V3","dir":1,"lat":22.620011,"lon":120.323181,"t":1794376396000,"rx":1794376422055},{"id":"051-V3","dir":0,"lat":22.615764,"lon":120.332331,"t":1794376392000,"rx":1794376422055},{"id":"056-V3","dir":0,"lat":22.616516,"lon":120.312053,"t":1794376395000,"rx":1794376422055},{"id":"059-V3","dir":0,"lat":22.62366,"lon":120.302914,"t":1794376394000,"rx":1794376422055},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376394000,"rx":1794376422055},{"id":"067-V3","dir":0,"lat":22.63353,"lon":120.302246,"t":1794376396000,"rx":1794376422055},{"id":"068-V3","dir":1,"lat":22.638343,"lon":120.301603,"t":1794376393000,"rx":1794376422055},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376396000,"rx":1794376422055},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376394000,"rx":1794376422055},{"id":"123-V3","dir":1,"lat":22.645623,"lon":120.300133,"t":1794376394000,"rx":1794376422055},{"id":"185-FT","dir":1,"lat":22.606513,"lon":120.333406,"t":1794376383000,"rx":1794376422055},{"id":"038-V3","dir":1,"lat":22.620051,"lon":120.323301,"t":1794376417000,"rx":1794376445889},{"id":"051-V3","dir":0,"lat":22.615764,"lon":120.332331,"t":1794376412000,"rx":1794376445889},{"id":"056-V3","dir":0,"lat":22.616516,"lon":120.312053,"t":1794376415000,"rx":1794376445889},{"id":"059-V3","dir":0,"lat":22.623905,"lon":120.303443,"t":1794376421000,"rx":1794376445889},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376414000,"rx":1794376445889},{"id":"067-V3","dir":0,"lat":22.634125,"lon":120.3023,"t":1794376421000,"rx":1794376445889},{"id":"068-V3","dir":1,"lat":22.637198,"lon":120.302321,"t":1794376414000,"rx":1794376445889},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376416000,"rx":1794376445889},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376414000,"rx":1794376445889},{"id":"123-V3","dir":1,"lat":22.645586,"lon":120.300771,"t":1794376412000,"rx":1794376445889},{"id":"185-FT","dir":1,"lat":22.605959,"lon":120.333486,"t":1794376422000,"rx":1794376445889},{"id":"038-V3","dir":1,"lat":22.620421,"lon":120.324396,"t":1794376456000,"rx":1794376484384},{"id":"051-V3","dir":0,"lat":22.615925,"lon":120.332324,"t":1794376452000,"rx":1794376484384},{"id":"056-V3","dir":0,"lat":22.615943,"lon":120.31037,"t":1794376455000,"rx":1794376484384},{"id":"059-V3","dir":0,"lat":22.624683,"lon":120.304843,"t":1794376454000,"rx":1794376484384},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376454000,"rx":1794376484384},{"id":"067-V3","dir":0,"lat":22.634206,"lon":120.302301,"t":1794376456000,"rx":1794376484384},{"id":"068-V3","dir":1,"lat":22.636331,"lon":120.302151,"t":1794376453000,"rx":1794376484384},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376456000,"rx":1794376484384},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376454000,"rx":1794376484384},{"id":"123-V3","dir":1,"lat":22.645495,"lon":120.30213,"t":1794376452000,"rx":1794376484384},{"id":"185-FT","dir":1,"lat":22.605696,"lon":120.333574,"t":1794376459000,"rx":1794376484384},{"id":"038-V3","dir":1,"lat":22.620908,"lon":120.325913,"t":1794376489000,"rx":1794376509087},{"id":"051-V3","dir":0,"lat":22.619133,"lon":120.331924,"t":1794376486000,"rx":1794376509087},{"id":"056-V3","dir":0,"lat":22.615636,"lon":120.309528,"t":1794376479000,"rx":1794376509087},{"id":"059-V3","dir":0,"lat":22.626256,"lon":120.304325,"t":1794376475000,"rx":1794376509087},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376474000,"rx":1794376509087},{"id":"067-V3","dir":0,"lat":22.634206,"lon":120.302301,"t":1794376476000,"rx":1794376509087},{"id":"068-V3","dir":1,"lat":22.636331,"lon":120.302151,"t":1794376473000,"rx":1794376509087},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376476000,"rx":1794376509087},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376474000,"rx":1794376509087},{"id":"123-V3","dir":1,"lat":22.645385,"lon":120.302899,"t":1794376472000,"rx":1794376509087},{"id":"185-FT","dir":1,"lat":22.605696,"lon":120.333574,"t":1794376482000,"rx":1794376509087},{"id":"038-V3","dir":1,"lat":22.621421,"lon":120.327498,"t":1794376516000,"rx":1794376532079},{"id":"051-V3","dir":0,"lat":22.620948,"lon":120.331725,"t":1794376512000,"rx":1794376532079},{"id":"056-V3","dir":0,"lat":22.614836,"lon":120.307036,"t":1794376515000,"rx":1794376532079},{"id":"059-V3","dir":0,"lat":22.627228,"lon":120.304304,"t":1794376515000,"rx":1794376532079},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376514000,"rx":1794376532079},{"id":"067-V3","dir":0,"lat":22.63435,"lon":120.302319,"t":1794376516000,"rx":1794376532079},{"id":"068-V3","dir":1,"lat":22.636258,"lon":120.302163,"t":1794376513000,"rx":1794376532079},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376516000,"rx":1794376532079},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376514000,"rx":1794376532079},{"id":"123-V3","dir":1,"lat":22.644196,"lon":120.302893,"t":1794376512000,"rx":1794376532079},{"id":"185-FT","dir":1,"lat":22.605676,"lon":120.334109,"t":1794376502000,"rx":1794376532079},{"id":"038-V3","dir":1,"lat":22.621421,"lon":120.327498,"t":1794376536000,"rx":1794376581176},{"id":"051-V3","dir":0,"lat":22.62112,"lon":120.33171,"t":1794376532000,"rx":1794376581176},{"id":"056-V3","dir":0,"lat":22.614836,"lon":120.307036,"t":1794376535000,"rx":1794376581176},{"id":"059-V3","dir":0,"lat":22.627453,"lon":120.303979,"t":1794376534000,"rx":1794376581176},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376534000,"rx":1794376581176},{"id":"067-V3","dir":0,"lat":22.635729,"lon":120.302424,"t":1794376536000,"rx":1794376581176},{"id":"068-V3","dir":1,"lat":22.634746,"lon":120.302143,"t":1794376543000,"rx":1794376581176},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376536000,"rx":1794376581176},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376534000,"rx":1794376581176},{"id":"123-V3","dir":1,"lat":22.642508,"lon":120.302766,"t":1794376532000,"rx":1794376581176},{"id":"185-FT","dir":1,"lat":22.605796,"lon":120.33534,"t":1794376542000,"rx":1794376581176},{"id":"038-V3","dir":1,"lat":22.621858,"lon":120.328771,"t":1794376578000,"rx":1794376604245},{"id":"051-V3","dir":0,"lat":22.622685,"lon":120.33109,"t":1794376572000,"rx":1794376604245},{"id":"056-V3","dir":0,"lat":22.614761,"lon":120.306881,"t":1794376575000,"rx":1794376604245},{"id":"059-V3","dir":0,"lat":22.627561,"lon":120.302143,"t":1794376574000,"rx":1794376604245},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376574000,"rx":1794376604245},{"id":"067-V3","dir":0,"lat":22.638298,"lon":120.303498,"t":1794376576000,"rx":1794376604245},{"id":"068-V3","dir":1,"lat":22.63173,"lon":120.301869,"t":1794376573000,"rx":1794376604245},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376576000,"rx":1794376604245},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376574000,"rx":1794376604245},{"id":"123-V3","dir":1,"lat":22.639926,"lon":120.301734,"t":1794376572000,"rx":1794376604245},{"id":"185-FT","dir":0,"lat":22.606038,"lon":120.338108,"t":1794376571000,"rx":1794376604245},{"id":"038-V3","dir":1,"lat":22.622424,"lon":120.330439,"t":1794376596000,"rx":1794376629045},{"id":"051-V3","dir":0,"lat":22.622153,"lon":120.329521,"t":1794376592000,"rx":1794376629045},{"id":"056-V3","dir":0,"lat":22.614329,"lon":120.306048,"t":1794376595000,"rx":1794376629045},{"id":"059-V3","dir":0,"lat":22.627561,"lon":120.30154,"t":1794376594000,"rx":1794376629045},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376594000,"rx":1794376629045},{"id":"067-V3","dir":0,"lat":22.639118,"lon":120.303548,"t":1794376596000,"rx":1794376629045},{"id":"068-V3","dir":1,"lat":22.63163,"lon":120.301863,"t":1794376593000,"rx":1794376629045},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376596000,"rx":1794376629045},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376594000,"rx":1794376629045},{"id":"123-V3","dir":1,"lat":22.639604,"lon":120.301625,"t":1794376592000,"rx":1794376629045},{"id":"185-FT","dir":0,"lat":22.606369,"lon":120.338624,"t":1794376602000,"rx":1794376629045},{"id":"038-V3","dir":1,"lat":22.620693,"lon":120.331704,"t":1794376636000,"rx":1794376652937},{"id":"051-V3","dir":0,"lat":22.621949,"lon":120.32881,"t":1794376635000,"rx":1794376652937},{"id":"056-V3","dir":0,"lat":22.614166,"lon":120.305508,"t":1794376639000,"rx":1794376652937},{"id":"059-V3","dir":0,"lat":22.628869,"lon":120.301723,"t":1794376634000,"rx":1794376652937},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376634000,"rx":1794376652937},{"id":"067-V3","dir":0,"lat":22.639118,"lon":120.303548,"t":1794376636000,"rx":1794376652937},{"id":"068-V3","dir":1,"lat":22.63163,"lon":120.301863,"t":1794376633000,"rx":1794376652937},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376636000,"rx":1794376652937},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376634000,"rx":1794376652937},{"id":"123-V3","dir":1,"lat":22.639604,"lon":120.301625,"t":1794376632000,"rx":1794376652937},{"id":"185-FT","dir":0,"lat":22.606176,"lon":120.338686,"t":1794376622000,"rx":1794376652937},{"id":"038-V3","dir":1,"lat":22.620291,"lon":120.331751,"t":1794376656000,"rx":1794376700181},{"id":"051-V3","dir":0,"lat":22.621356,"lon":120.327076,"t":1794376657000,"rx":1794376700181},{"id":"056-V3","dir":0,"lat":22.614119,"lon":120.304946,"t":1794376655000,"rx":1794376700181},{"id":"059-V3","dir":0,"lat":22.629576,"lon":120.301796,"t":1794376669000,"rx":1794376700181},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376654000,"rx":1794376700181},{"id":"067-V3","dir":0,"lat":22.639189,"lon":120.303546,"t":1794376657000,"rx":1794376700181},{"id":"068-V3","dir":1,"lat":22.63163,"lon":120.301863,"t":1794376653000,"rx":1794376700181},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376656000,"rx":1794376700181},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376654000,"rx":1794376700181},{"id":"123-V3","dir":1,"lat":22.639604,"lon":120.301625,"t":1794376652000,"rx":1794376700181},{"id":"185-FT","dir":0,"lat":22.606211,"lon":120.338626,"t":1794376662000,"rx":1794376700181},{"id":"038-V3","dir":1,"lat":22.61984,"lon":120.331819,"t":1794376696000,"rx":1794376724129},{"id":"051-V3","dir":0,"lat":22.621349,"lon":120.327034,"t":1794376692000,"rx":1794376724129},{"id":"056-V3","dir":0,"lat":22.614119,"lon":120.304946,"t":1794376695000,"rx":1794376724129},{"id":"059-V3","dir":0,"lat":22.631969,"lon":120.302066,"t":1794376694000,"rx":1794376724129},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376694000,"rx":1794376724129},{"id":"067-V3","dir":0,"lat":22.641835,"lon":120.302903,"t":1794376696000,"rx":1794376724129},{"id":"068-V3","dir":1,"lat":22.630406,"lon":120.301681,"t":1794376693000,"rx":1794376724129},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376696000,"rx":1794376724129},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376694000,"rx":1794376724129},{"id":"123-V3","dir":1,"lat":22.637188,"lon":120.302308,"t":1794376698000,"rx":1794376724129},{"id":"185-FT","dir":0,"lat":22.606206,"lon":120.338516,"t":1794376690000,"rx":1794376724129},{"id":"038-V3","dir":1,"lat":22.617536,"lon":120.332083,"t":1794376723000,"rx":1794376746588},{"id":"051-V3","dir":0,"lat":22.621349,"lon":120.327034,"t":1794376712000,"rx":1794376746588},{"id":"056-V3","dir":0,"lat":22.614119,"lon":120.304946,"t":1794376715000,"rx":1794376746588},{"id":"059-V3","dir":0,"lat":22.634121,"lon":120.302215,"t":1794376716000,"rx":1794376746588},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376714000,"rx":1794376746588},{"id":"067-V3","dir":0,"lat":22.644121,"lon":120.30306,"t":1794376716000,"rx":1794376746588},{"id":"068-V3","dir":1,"lat":22.630406,"lon":120.301681,"t":1794376713000,"rx":1794376746588},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376716000,"rx":1794376746588},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376714000,"rx":1794376746588},{"id":"123-V3","dir":1,"lat":22.635259,"lon":120.302111,"t":1794376723000,"rx":1794376746588},{"id":"038-V3","dir":1,"lat":22.613483,"lon":120.332594,"t":1794376759000,"rx":1794376771004},{"id":"051-V3","dir":0,"lat":22.620993,"lon":120.325954,"t":1794376752000,"rx":1794376771004},{"id":"056-V3","dir":0,"lat":22.614086,"lon":120.304771,"t":1794376755000,"rx":1794376771004},{"id":"059-V3","dir":0,"lat":22.634266,"lon":120.302216,"t":1794376754000,"rx":1794376771004},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376754000,"rx":1794376771004},{"id":"067-V3","dir":0,"lat":22.645649,"lon":120.301013,"t":1794376756000,"rx":1794376771004},{"id":"068-V3","dir":1,"lat":22.629449,"lon":120.301691,"t":1794376753000,"rx":1794376771004},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376756000,"rx":1794376771004},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376754000,"rx":1794376771004},{"id":"123-V3","dir":1,"lat":22.634946,"lon":120.302098,"t":1794376752000,"rx":1794376771004},{"id":"038-V3","dir":1,"lat":22.613305,"lon":120.332618,"t":1794376776000,"rx":1794376809416},{"id":"051-V3","dir":0,"lat":22.620093,"lon":120.323241,"t":1794376788000,"rx":1794376809416},{"id":"056-V3","dir":0,"lat":22.613475,"lon":120.302981,"t":1794376777000,"rx":1794376809416},{"id":"059-V3","dir":0,"lat":22.634266,"lon":120.302216,"t":1794376774000,"rx":1794376809416},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376774000,"rx":1794376809416},{"id":"067-V3","dir":0,"lat":22.646028,"lon":120.299851,"t":1794376776000,"rx":1794376809416},{"id":"068-V3","dir":1,"lat":22.629449,"lon":120.301691,"t":1794376773000,"rx":1794376809416},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376776000,"rx":1794376809416},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376774000,"rx":1794376809416},{"id":"123-V3","dir":1,"lat":22.634946,"lon":120.302098,"t":1794376772000,"rx":1794376809416},{"id":"038-V3","dir":1,"lat":22.612989,"lon":120.332624,"t":1794376816000,"rx":1794376833257},{"id":"051-V3","dir":0,"lat":22.619791,"lon":120.322298,"t":1794376812000,"rx":1794376833257},{"id":"056-V3","dir":0,"lat":22.612548,"lon":120.300503,"t":1794376815000,"rx":1794376833257},{"id":"058-V3","dir":0,"lat":22.606188,"lon":120.338646,"t":1794376819000,"rx":1794376833257},{"id":"059-V3","dir":0,"lat":22.634464,"lon":120.302238,"t":1794376814000,"rx":1794376833257},{"id":"063-V3","dir":0,"lat":22.671363,"lon":120.30095,"t":1794376814000,"rx":1794376833257},{"id":"067-V3","dir":0,"lat":22.646603,"lon":120.299906,"t":1794376816000,"rx":1794376833257},{"id":"068-V3","dir":1,"lat":22.629331,"lon":120.301678,"t":1794376813000,"rx":1794376833257},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376816000,"rx":1794376833257},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376814000,"rx":1794376833257},{"id":"123-V3","dir":1,"lat":22.634769,"lon":120.302049,"t":1794376816000,"rx":1794376833257},{"id":"038-V3","dir":1,"lat":22.612704,"lon":120.332648,"t":1794376836000,"rx":1794376879594},{"id":"051-V3","dir":0,"lat":22.619791,"lon":120.322298,"t":1794376832000,"rx":1794376879594},{"id":"056-V3","dir":0,"lat":22.612548,"lon":120.300503,"t":1794376835000,"rx":1794376879594},{"id":"058-V3","dir":0,"lat":22.606075,"lon":120.338421,"t":1794376839000,"rx":1794376879594},{"id":"059-V3","dir":0,"lat":22.636296,"lon":120.302374,"t":1794376834000,"rx":1794376879594},{"id":"063-V3","dir":0,"lat":22.670956,"lon":120.301148,"t":1794376834000,"rx":1794376879594},{"id":"067-V3","dir":0,"lat":22.647378,"lon":120.299959,"t":1794376836000,"rx":1794376879594},{"id":"068-V3","dir":1,"lat":22.62809,"lon":120.301543,"t":1794376833000,"rx":1794376879594},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376836000,"rx":1794376879594},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376834000,"rx":1794376879594},{"id":"123-V3","dir":1,"lat":22.633241,"lon":120.301963,"t":1794376834000,"rx":1794376879594},{"id":"038-V3","dir":1,"lat":22.609393,"lon":120.333021,"t":1794376876000,"rx":1794376903189},{"id":"051-V3","dir":0,"lat":22.619263,"lon":120.320745,"t":1794376879000,"rx":1794376903189},{"id":"056-V3","dir":0,"lat":22.612114,"lon":120.299385,"t":1794376875000,"rx":1794376903189},{"id":"058-V3","dir":0,"lat":22.606081,"lon":120.338329,"t":1794376879000,"rx":1794376903189},{"id":"059-V3","dir":0,"lat":22.639413,"lon":120.303476,"t":1794376874000,"rx":1794376903189},{"id":"063-V3","dir":0,"lat":22.670801,"lon":120.301676,"t":1794376874000,"rx":1794376903189},{"id":"067-V3","dir":0,"lat":22.647761,"lon":120.299994,"t":1794376876000,"rx":1794376903189},{"id":"068-V3","dir":1,"lat":22.627388,"lon":120.301493,"t":1794376873000,"rx":1794376903189},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376876000,"rx":1794376903189},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376874000,"rx":1794376903189},{"id":"123-V3","dir":1,"lat":22.628951,"lon":120.301609,"t":1794376872000,"rx":1794376903189},{"id":"038-V3","dir":1,"lat":22.609341,"lon":120.333026,"t":1794376896000,"rx":1794376927482},{"id":"051-V3","dir":0,"lat":22.61897,"lon":120.319834,"t":1794376892000,"rx":1794376927482},{"id":"056-V3","dir":0,"lat":22.612363,"lon":120.298518,"t":1794376895000,"rx":1794376927482},{"id":"058-V3","dir":0,"lat":22.605953,"lon":120.33669,"t":1794376899000,"rx":1794376927482},{"id":"059-V3","dir":0,"lat":22.640216,"lon":120.303561,"t":1794376894000,"rx":1794376927482},{"id":"063-V3","dir":1,"lat":22.670539,"lon":120.302781,"t":1794376894000,"rx":1794376927482},{"id":"067-V3","dir":0,"lat":22.648503,"lon":120.300061,"t":1794376896000,"rx":1794376927482},{"id":"068-V3","dir":1,"lat":22.627191,"lon":120.303886,"t":1794376905000,"rx":1794376927482},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376896000,"rx":1794376927482},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376894000,"rx":1794376927482},{"id":"123-V3","dir":1,"lat":22.627708,"lon":120.301501,"t":1794376892000,"rx":1794376927482},{"id":"038-V3","dir":1,"lat":22.609341,"lon":120.333026,"t":1794376916000,"rx":1794376950347},{"id":"051-V3","dir":0,"lat":22.618743,"lon":120.319194,"t":1794376919000,"rx":1794376950347},{"id":"056-V3","dir":0,"lat":22.612651,"lon":120.298433,"t":1794376915000,"rx":1794376950347},{"id":"058-V3","dir":0,"lat":22.605765,"lon":120.334594,"t":1794376919000,"rx":1794376950347},{"id":"059-V3","dir":0,"lat":22.640216,"lon":120.303561,"t":1794376914000,"rx":1794376950347},{"id":"063-V3","dir":1,"lat":22.67012,"lon":120.303308,"t":1794376914000,"rx":1794376950347},{"id":"067-V3","dir":0,"lat":22.650118,"lon":120.300213,"t":1794376916000,"rx":1794376950347},{"id":"068-V3","dir":1,"lat":22.62631,"lon":120.304315,"t":1794376927000,"rx":1794376950347},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376916000,"rx":1794376950347},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376914000,"rx":1794376950347},{"id":"123-V3","dir":1,"lat":22.627708,"lon":120.301501,"t":1794376912000,"rx":1794376950347},{"id":"038-V3","dir":1,"lat":22.606503,"lon":120.333423,"t":1794376958000,"rx":1794376989075},{"id":"051-V3","dir":0,"lat":22.618294,"lon":120.317258,"t":1794376952000,"rx":1794376989075},{"id":"056-V3","dir":0,"lat":22.614004,"lon":120.297876,"t":1794376955000,"rx":1794376989075},{"id":"058-V3","dir":0,"lat":22.60573,"lon":120.334008,"t":1794376959000,"rx":1794376989075},{"id":"059-V3","dir":0,"lat":22.640216,"lon":120.303561,"t":1794376954000,"rx":1794376989075},{"id":"063-V3","dir":1,"lat":22.668514,"lon":120.303318,"t":1794376954000,"rx":1794376989075},{"id":"067-V3","dir":0,"lat":22.650376,"lon":120.300229,"t":1794376956000,"rx":1794376989075},{"id":"068-V3","dir":1,"lat":22.624714,"lon":120.304791,"t":1794376953000,"rx":1794376989075},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376956000,"rx":1794376989075},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376954000,"rx":1794376989075},{"id":"123-V3","dir":1,"lat":22.627638,"lon":120.30149,"t":1794376952000,"rx":1794376989075},{"id":"038-V3","dir":1,"lat":22.605693,"lon":120.33387,"t":1794376976000,"rx":1794377012158},{"id":"051-V3","dir":0,"lat":22.618023,"lon":120.316633,"t":1794376976000,"rx":1794377012158},{"id":"056-V3","dir":0,"lat":22.614571,"lon":120.297624,"t":1794376980000,"rx":1794377012158},{"id":"058-V3","dir":0,"lat":22.60575,"lon":120.333664,"t":1794376988000,"rx":1794377012158},{"id":"059-V3","dir":0,"lat":22.641038,"lon":120.303235,"t":1794376974000,"rx":1794377012158},{"id":"063-V3","dir":1,"lat":22.667491,"lon":120.303296,"t":1794376974000,"rx":1794377012158},{"id":"067-V3","dir":0,"lat":22.651178,"lon":120.300796,"t":1794376976000,"rx":1794377012158},{"id":"068-V3","dir":1,"lat":22.624481,"lon":120.304853,"t":1794376973000,"rx":1794377012158},{"id":"111-V3","dir":0,"lat":22.606411,"lon":120.338323,"t":1794376976000,"rx":1794377012158},{"id":"120-V3","dir":0,"lat":22.606606,"lon":120.338488,"t":1794376974000,"rx":1794377012158},{"id":"123-V3","dir":1,"lat":22.627516,"lon":120.301484,"t":1794376972000,"rx":1794377012158},{"id":"038-V3","dir":1,"lat":22.605816,"lon":120.335198,"t":1794377016000,"rx":1794377037592},{"id":"051-V3","dir":0,"lat":22.617913,"lon":120.31632,"t":1794377012000,"rx":1794377037592},{"id":"056-V3","dir":0,"lat":22.615626,"lon":120.297168,"t":1794377015000,"rx":1794377037592},{"id":"058-V3","dir":0,"lat":22.607928,"lon":120.333306,"t":1794377019000,"rx":1794377037592},{"id":"059-V3","dir":0,"lat":22.644825,"lon":120.303049,"t":1794377014000,"rx":1794377037592},{"id":"063-V3","dir":1,"lat":22.664918,"lon":120.303264,"t":1794377014000,"rx":1794377037592},{"id":"067-V3","dir":0,"lat":22.651523,"lon":120.302194,"t":1794377016000,"rx":1794377037592},{"id":"068-V3","dir":1,"lat":22.624095,"lon":120.303788,"t":1794377013000,"rx":1794377037592},{"id":"111-V3","dir":0,"lat":22.606321,"lon":120.338626,"t":1794377016000,"rx":1794377037592},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377014000,"rx":1794377037592},{"id":"123-V3","dir":1,"lat":22.627516,"lon":120.301484,"t":1794377012000,"rx":1794377037592},{"id":"038-V3","dir":0,"lat":22.606,"lon":120.338071,"t":1794377041000,"rx":1794377070377},{"id":"051-V3","dir":0,"lat":22.617326,"lon":120.314551,"t":1794377048000,"rx":1794377070377},{"id":"056-V3","dir":0,"lat":22.616173,"lon":120.296936,"t":1794377042000,"rx":1794377070377},{"id":"058-V3","dir":0,"lat":22.608029,"lon":120.333299,"t":1794377048000,"rx":1794377070377},{"id":"059-V3","dir":0,"lat":22.645595,"lon":120.301953,"t":1794377048000,"rx":1794377070377},{"id":"063-V3","dir":1,"lat":22.664425,"lon":120.303206,"t":1794377034000,"rx":1794377070377},{"id":"067-V3","dir":0,"lat":22.651665,"lon":120.302473,"t":1794377036000,"rx":1794377070377},{"id":"068-V3","dir":1,"lat":22.623601,"lon":120.302286,"t":1794377045000,"rx":1794377070377},{"id":"111-V3","dir":0,"lat":22.606353,"lon":120.338634,"t":1794377036000,"rx":1794377070377},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377034000,"rx":1794377070377},{"id":"123-V3","dir":1,"lat":22.627183,"lon":120.303908,"t":1794377049000,"rx":1794377070377},{"id":"038-V3","dir":0,"lat":22.606453,"lon":120.338558,"t":1794377076000,"rx":1794377109397},{"id":"051-V3","dir":0,"lat":22.616291,"lon":120.311496,"t":1794377077000,"rx":1794377109397},{"id":"056-V3","dir":0,"lat":22.618078,"lon":120.296189,"t":1794377075000,"rx":1794377109397},{"id":"058-V3","dir":0,"lat":22.609954,"lon":120.333076,"t":1794377079000,"rx":1794377109397},{"id":"059-V3","dir":0,"lat":22.645633,"lon":120.301355,"t":1794377074000,"rx":1794377109397},{"id":"063-V3","dir":1,"lat":22.663236,"lon":120.303161,"t":1794377074000,"rx":1794377109397},{"id":"067-V3","dir":0,"lat":22.651791,"lon":120.303021,"t":1794377076000,"rx":1794377109397},{"id":"068-V3","dir":1,"lat":22.623235,"lon":120.301109,"t":1794377079000,"rx":1794377109397},{"id":"111-V3","dir":0,"lat":22.606353,"lon":120.338634,"t":1794377076000,"rx":1794377109397},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377074000,"rx":1794377109397},{"id":"123-V3","dir":1,"lat":22.626844,"lon":120.304239,"t":1794377072000,"rx":1794377109397},{"id":"038-V3","dir":0,"lat":22.605723,"lon":120.338506,"t":1794377096000,"rx":1794377133569},{"id":"051-V3","dir":0,"lat":22.615501,"lon":120.309539,"t":1794377095000,"rx":1794377133569},{"id":"056-V3","dir":0,"lat":22.619271,"lon":120.295831,"t":1794377095000,"rx":1794377133569},{"id":"058-V3","dir":0,"lat":22.611026,"lon":120.332916,"t":1794377099000,"rx":1794377133569},{"id":"059-V3","dir":0,"lat":22.645713,"lon":120.300198,"t":1794377094000,"rx":1794377133569},{"id":"063-V3","dir":1,"lat":22.663236,"lon":120.303161,"t":1794377094000,"rx":1794377133569},{"id":"067-V3","dir":0,"lat":22.652103,"lon":120.303758,"t":1794377096000,"rx":1794377133569},{"id":"068-V3","dir":1,"lat":22.62301,"lon":120.300323,"t":1794377093000,"rx":1794377133569},{"id":"111-V3","dir":0,"lat":22.606353,"lon":120.338634,"t":1794377096000,"rx":1794377133569},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377094000,"rx":1794377133569},{"id":"123-V3","dir":1,"lat":22.625686,"lon":120.304445,"t":1794377092000,"rx":1794377133569},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377136000,"rx":1794377155188},{"id":"051-V3","dir":0,"lat":22.615348,"lon":120.308823,"t":1794377132000,"rx":1794377155188},{"id":"056-V3","dir":0,"lat":22.619428,"lon":120.295761,"t":1794377135000,"rx":1794377155188},{"id":"058-V3","dir":0,"lat":22.612188,"lon":120.332759,"t":1794377139000,"rx":1794377155188},{"id":"059-V3","dir":0,"lat":22.645721,"lon":120.299931,"t":1794377134000,"rx":1794377155188},{"id":"063-V3","dir":1,"lat":22.661193,"lon":120.303091,"t":1794377137000,"rx":1794377155188},{"id":"067-V3","dir":0,"lat":22.655143,"lon":120.303608,"t":1794377136000,"rx":1794377155188},{"id":"068-V3","dir":1,"lat":22.622734,"lon":120.299633,"t":1794377133000,"rx":1794377155188},{"id":"111-V3","dir":0,"lat":22.606353,"lon":120.338634,"t":1794377136000,"rx":1794377155188},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377134000,"rx":1794377155188},{"id":"123-V3","dir":1,"lat":22.624265,"lon":120.304351,"t":1794377132000,"rx":1794377155188},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377156000,"rx":1794377180893},{"id":"051-V3","dir":0,"lat":22.615348,"lon":120.308823,"t":1794377152000,"rx":1794377180893},{"id":"056-V3","dir":0,"lat":22.619428,"lon":120.295761,"t":1794377155000,"rx":1794377180893},{"id":"058-V3","dir":0,"lat":22.612188,"lon":120.332759,"t":1794377159000,"rx":1794377180893},{"id":"059-V3","dir":0,"lat":22.645721,"lon":120.299931,"t":1794377154000,"rx":1794377180893},{"id":"063-V3","dir":1,"lat":22.659678,"lon":120.303038,"t":1794377154000,"rx":1794377180893},{"id":"067-V3","dir":0,"lat":22.656539,"lon":120.303314,"t":1794377161000,"rx":1794377180893},{"id":"068-V3","dir":1,"lat":22.62263,"lon":120.29934,"t":1794377153000,"rx":1794377180893},{"id":"111-V3","dir":0,"lat":22.606353,"lon":120.338634,"t":1794377156000,"rx":1794377180893},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377154000,"rx":1794377180893},{"id":"123-V3","dir":1,"lat":22.623864,"lon":120.303228,"t":1794377166000,"rx":1794377180893},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377196000,"rx":1794377229086},{"id":"051-V3","dir":0,"lat":22.614761,"lon":120.306906,"t":1794377197000,"rx":1794377229086},{"id":"056-V3","dir":0,"lat":22.620936,"lon":120.295098,"t":1794377195000,"rx":1794377229086},{"id":"058-V3","dir":0,"lat":22.613433,"lon":120.332636,"t":1794377199000,"rx":1794377229086},{"id":"059-V3","dir":0,"lat":22.646386,"lon":120.299846,"t":1794377194000,"rx":1794377229086},{"id":"063-V3","dir":1,"lat":22.657143,"lon":120.30301,"t":1794377194000,"rx":1794377229086},{"id":"067-V3","dir":0,"lat":22.657103,"lon":120.303274,"t":1794377196000,"rx":1794377229086},{"id":"068-V3","dir":1,"lat":22.622595,"lon":120.299218,"t":1794377193000,"rx":1794377229086},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377196000,"rx":1794377229086},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377194000,"rx":1794377229086},{"id":"123-V3","dir":1,"lat":22.623426,"lon":120.301716,"t":1794377192000,"rx":1794377229086},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377216000,"rx":1794377253027},{"id":"051-V3","dir":0,"lat":22.614431,"lon":120.305994,"t":1794377212000,"rx":1794377253027},{"id":"056-V3","dir":0,"lat":22.621366,"lon":120.296081,"t":1794377215000,"rx":1794377253027},{"id":"058-V3","dir":0,"lat":22.614758,"lon":120.332464,"t":1794377219000,"rx":1794377253027},{"id":"059-V3","dir":0,"lat":22.64701,"lon":120.299896,"t":1794377216000,"rx":1794377253027},{"id":"063-V3","dir":1,"lat":22.656873,"lon":120.303055,"t":1794377214000,"rx":1794377253027},{"id":"067-V3","dir":0,"lat":22.657873,"lon":120.30319,"t":1794377216000,"rx":1794377253027},{"id":"068-V3","dir":1,"lat":22.622206,"lon":120.298033,"t":1794377215000,"rx":1794377253027},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377216000,"rx":1794377253027},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377214000,"rx":1794377253027},{"id":"123-V3","dir":1,"lat":22.623243,"lon":120.301124,"t":1794377226000,"rx":1794377253027},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377256000,"rx":1794377277200},{"id":"051-V3","dir":0,"lat":22.614243,"lon":120.305468,"t":1794377252000,"rx":1794377277200},{"id":"056-V3","dir":0,"lat":22.62179,"lon":120.297241,"t":1794377255000,"rx":1794377277200},{"id":"058-V3","dir":0,"lat":22.615735,"lon":120.332346,"t":1794377259000,"rx":1794377277200},{"id":"059-V3","dir":0,"lat":22.647725,"lon":120.299968,"t":1794377254000,"rx":1794377277200},{"id":"063-V3","dir":1,"lat":22.65659,"lon":120.303129,"t":1794377254000,"rx":1794377277200},{"id":"067-V3","dir":0,"lat":22.657988,"lon":120.3032,"t":1794377256000,"rx":1794377277200},{"id":"068-V3","dir":1,"lat":22.621898,"lon":120.29721,"t":1794377253000,"rx":1794377277200},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377256000,"rx":1794377277200},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377254000,"rx":1794377277200},{"id":"123-V3","dir":1,"lat":22.62262,"lon":120.299303,"t":1794377252000,"rx":1794377277200},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377276000,"rx":1794377323219},{"id":"051-V3","dir":0,"lat":22.6141,"lon":120.304958,"t":1794377272000,"rx":1794377323219},{"id":"056-V3","dir":0,"lat":22.621866,"lon":120.297671,"t":1794377275000,"rx":1794377323219},{"id":"058-V3","dir":0,"lat":22.615735,"lon":120.332346,"t":1794377279000,"rx":1794377323219},{"id":"059-V3","dir":0,"lat":22.648831,"lon":120.300051,"t":1794377274000,"rx":1794377323219},{"id":"063-V3","dir":1,"lat":22.656313,"lon":120.303191,"t":1794377286000,"rx":1794377323219},{"id":"067-V3","dir":0,"lat":22.660203,"lon":120.303188,"t":1794377289000,"rx":1794377323219},{"id":"068-V3","dir":1,"lat":22.62141,"lon":120.295793,"t":1794377273000,"rx":1794377323219},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377276000,"rx":1794377323219},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377274000,"rx":1794377323219},{"id":"123-V3","dir":1,"lat":22.622621,"lon":120.299306,"t":1794377272000,"rx":1794377323219},{"id":"038-V3","dir":0,"lat":22.604651,"lon":120.33668,"t":1794377316000,"rx":1794377347811},{"id":"051-V3","dir":0,"lat":22.6141,"lon":120.304958,"t":1794377312000,"rx":1794377347811},{"id":"056-V3","dir":0,"lat":22.621948,"lon":120.297891,"t":1794377315000,"rx":1794377347811},{"id":"058-V3","dir":0,"lat":22.618728,"lon":120.331946,"t":1794377319000,"rx":1794377347811},{"id":"059-V3","dir":0,"lat":22.650296,"lon":120.300181,"t":1794377314000,"rx":1794377347811},{"id":"063-V3","dir":1,"lat":22.654126,"lon":120.303578,"t":1794377314000,"rx":1794377347811},{"id":"067-V3","dir":0,"lat":22.662831,"lon":120.303283,"t":1794377316000,"rx":1794377347811},{"id":"068-V3","dir":1,"lat":22.62123,"lon":120.295186,"t":1794377313000,"rx":1794377347811},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377316000,"rx":1794377347811},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377314000,"rx":1794377347811},{"id":"123-V3","dir":1,"lat":22.622621,"lon":120.299306,"t":1794377312000,"rx":1794377347811},{"id":"038-V3","dir":0,"lat":22.604668,"lon":120.336581,"t":1794377336000,"rx":1794377362313},{"id":"051-V3","dir":0,"lat":22.6141,"lon":120.304958,"t":1794377332000,"rx":1794377362313},{"id":"056-V3","dir":0,"lat":22.622099,"lon":120.29823,"t":1794377335000,"rx":1794377362313},{"id":"058-V3","dir":0,"lat":22.619806,"lon":120.331833,"t":1794377339000,"rx":1794377362313},{"id":"059-V3","dir":0,"lat":22.650503,"lon":120.300174,"t":1794377334000,"rx":1794377362313},{"id":"063-V3","dir":1,"lat":22.652281,"lon":120.303568,"t":1794377334000,"rx":1794377362313},{"id":"067-V3","dir":0,"lat":22.664511,"lon":120.30334,"t":1794377336000,"rx":1794377362313},{"id":"068-V3","dir":1,"lat":22.62123,"lon":120.295186,"t":1794377333000,"rx":1794377362313},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377336000,"rx":1794377362313},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377334000,"rx":1794377362313},{"id":"123-V3","dir":1,"lat":22.622621,"lon":120.299306,"t":1794377332000,"rx":1794377362313},{"id":"038-V3","dir":0,"lat":22.604668,"lon":120.336581,"t":1794377376000,"rx":1794377407256},{"id":"051-V3","dir":0,"lat":22.613414,"lon":120.302925,"t":1794377375000,"rx":1794377407256},{"id":"056-V3","dir":0,"lat":22.622885,"lon":120.300573,"t":1794377379000,"rx":1794377407256},{"id":"058-V3","dir":0,"lat":22.619876,"lon":120.331824,"t":1794377379000,"rx":1794377407256},{"id":"059-V3","dir":0,"lat":22.651416,"lon":120.30177,"t":1794377379000,"rx":1794377407256},{"id":"063-V3","dir":1,"lat":22.652239,"lon":120.303569,"t":1794377374000,"rx":1794377407256},{"id":"067-V3","dir":0,"lat":22.665206,"lon":120.303375,"t":1794377376000,"rx":1794377407256},{"id":"068-V3","dir":1,"lat":22.621081,"lon":120.294948,"t":1794377373000,"rx":1794377407256},{"id":"111-V3","dir":0,"lat":22.60642,"lon":120.338713,"t":1794377376000,"rx":1794377407256},{"id":"120-V3","dir":0,"lat":22.606629,"lon":120.338581,"t":1794377374000,"rx":1794377407256},{"id":"123-V3","dir":1,"lat":22.621898,"lon":120.297361,"t":1794377372000,"rx":1794377407256}],"boardingEta":[{"t":1794376163693,"eta":150,"arriving":false},{"t":1794376170785,"eta":150,"arriving":false},{"t":1794376185566,"eta":127,"arriving":false},{"t":1794376195395,"eta":127,"arriving":false},{"t":1794376210922,"eta":141,"arriving":false},{"t":1794376218989,"eta":141,"arriving":false},{"t":1794376234963,"eta":161,"arriving":false},{"t":1794376242899,"eta":161,"arriving":false},{"t":1794376257718,"eta":137,"arriving":false},{"t":1794376266920,"eta":137,"arriving":false},{"t":1794376280943,"eta":91,"arriving":false},{"t":1794376293308,"eta":91,"arriving":false},{"t":1794376303386,"eta":70,"arriving":false},{"t":1794376315250,"eta":70,"arriving":false},{"t":1794376329068,"eta":24,"arriving":false},{"t":1794376339679,"eta":24,"arriving":false},{"t":1794376353152,"eta":-1,"arriving":false},{"t":1794376364015,"eta":-1,"arriving":false},{"t":1794376377495,"eta":17,"arriving":false},{"t":1794376387892,"eta":17,"arriving":false},{"t":1794376400173,"eta":-1,"arriving":true},{"t":1794376413923,"eta":-1,"arriving":true},{"t":1794376423944,"eta":-1,"arriving":false},{"t":1794376436583,"eta":-1,"arriving":false},{"t":1794376447758,"eta":null,"arriving":false},{"t":1794376462688,"eta":null,"arriving":false},{"t":1794376470669,"eta":null,"arriving":false},{"t":1794376486042,"eta":null,"arriving":false},{"t":1794376494567,"eta":null,"arriving":false},{"t":1794376510540,"eta":null,"arriving":false},{"t":1794376518657,"eta":null,"arriving":false},{"t":1794376533733,"eta":null,"arriving":false},{"t":1794376542498,"eta":null,"arriving":false},{"t":1794376558066,"eta":null,"arriving":false},{"t":1794376566529,"eta":null,"arriving":false},{"t":1794376583036,"eta":null,"arriving":false},{"t":1794376590852,"eta":null,"arriving":false},{"t":1794376606060,"eta":null,"arriving":false},{"t":1794376614538,"eta":null,"arriving":false},{"t":1794376631050,"eta":null,"arriving":false},{"t":1794376638601,"eta":null,"arriving":false},{"t":1794376654789,"eta":null,"arriving":false},{"t":1794376662715,"eta":null,"arriving":false},{"t":1794376678137,"eta":null,"arriving":false},{"t":1794376686707,"eta":null,"arriving":false},{"t":1794376701601,"eta":null,"arriving":false},{"t":1794376710635,"eta":null,"arriving":false},{"t":1794376725946,"eta":null,"arriving":false},{"t":1794376734640,"eta":null,"arriving":false},{"t":1794376748432,"eta":null,"arriving":false},{"t":1794376760440,"eta":null,"arriving":false},{"t":1794376773065,"eta":null,"arriving":false},{"t":1794376787810,"eta":null,"arriving":false},{"t":1794376794892,"eta":null,"arriving":false},{"t":1794376811581,"eta":null,"arriving":false},{"t":1794376818676,"eta":null,"arriving":false},{"t":1794376835317,"eta":null,"arriving":false},{"t":1794376842926,"eta":null,"arriving":false},{"t":1794376857517,"eta":null,"arriving":false},{"t":1794376867718,"eta":null,"arriving":false},{"t":1794376881564,"eta":null,"arriving":false},{"t":1794376891608,"eta":null,"arriving":false},{"t":1794376904823,"eta":null,"arriving":false},{"t":1794376915664,"eta":null,"arriving":false},{"t":1794376929445,"eta":null,"arriving":false},{"t":1794376939768,"eta":null,"arriving":false},{"t":1794376952162,"eta":null,"arriving":false},{"t":1794376965858,"eta":146,"arriving":false},{"t":1794376974751,"eta":146,"arriving":false},{"t":1794376991088,"eta":123,"arriving":false},{"t":1794376998672,"eta":123,"arriving":false},{"t":1794377014080,"eta":139,"arriving":false},{"t":1794377022957,"eta":139,"arriving":false},{"t":1794377039542,"eta":153,"arriving":false},{"t":1794377047432,"eta":153,"arriving":false},{"t":1794377063068,"eta":132,"arriving":false},{"t":1794377071026,"eta":132,"arriving":false},{"t":1794377087501,"eta":86,"arriving":false},{"t":1794377095040,"eta":86,"arriving":false},{"t":1794377111998,"eta":60,"arriving":false},{"t":1794377119064,"eta":60,"arriving":false},{"t":1794377136046,"eta":79,"arriving":false},{"t":1794377142471,"eta":79,"arriving":false},{"t":1794377157213,"eta":38,"arriving":false},{"t":1794377166632,"eta":38,"arriving":false},{"t":1794377183321,"eta":11,"arriving":false},{"t":1794377191087,"eta":11,"arriving":false},{"t":1794377207416,"eta":27,"arriving":false},{"t":1794377214790,"eta":27,"arriving":false},{"t":1794377230944,"eta":3,"arriving":false},{"t":1794377239022,"eta":3,"arriving":false},{"t":1794377254989,"eta":-1,"arriving":true},{"t":1794377263033,"eta":-1,"arriving":true},{"t":1794377279609,"eta":null,"arriving":false},{"t":1794377287475,"eta":null,"arriving":false},{"t":1794377300197,"eta":null,"arriving":false},{"t":1794377313305,"eta":null,"arriving":false},{"t":1794377324991,"eta":null,"arriving":false},{"t":1794377335180,"eta":null,"arriving":false},{"t":1794377349476,"eta":null,"arriving":false},{"t":1794377364211,"eta":null,"arriving":false},{"t":1794377370793,"eta":null,"arriving":false},{"t":1794377385536,"eta":null,"arriving":false},{"t":1794377395370,"eta":null,"arriving":false},{"t":1794377409111,"eta":null,"arriving":false}],"riderFixes":[{"t":1794376163000,"lat":22.614211,"lon":120.332806,"acc":15.0,"spd":0.2},{"t":1794376168000,"lat":22.614346,"lon":120.332437,"acc":15.0,"spd":0.4},{"t":1794376173000,"lat":22.614289,"lon":120.33256,"acc":15.0,"spd":0.0},{"t":1794376178000,"lat":22.614368,"lon":120.332548,"acc":15.0,"spd":0.4},{"t":1794376183000,"lat":22.614092,"lon":120.33255,"acc":15.0,"spd":0.0},{"t":1794376188000,"lat":22.614397,"lon":120.332507,"acc":15.0,"spd":0.0},{"t":1794376193000,"lat":22.614159,"lon":120.332511,"acc":15.0,"spd":0.1},{"t":1794376198000,"lat":22.614248,"lon":120.332806,"acc":15.0,"spd":0.0},{"t":1794376203000,"lat":22.614253,"lon":120.3325,"acc":15.0,"spd":0.3},{"t":1794376208000,"lat":22.614319,"lon":120.332616,"acc":15.0,"spd":0.0},{"t":1794376213000,"lat":22.614303,"lon":120.332642,"acc":15.0,"spd":0.3},{"t":1794376218000,"lat":22.614321,"lon":120.332747,"acc":15.0,"spd":0.2},{"t":1794376223000,"lat":22.614165,"lon":120.332389,"acc":15.0,"spd":0.3},{"t":1794376228000,"lat":22.614421,"lon":120.332775,"acc":15.0,"spd":0.3},{"t":1794376233000,"lat":22.614478,"lon":120.332421,"acc":15.0,"spd":0.0},{"t":1794376238000,"lat":22.61434,"lon":120.332595,"acc":15.0,"spd":0.4},{"t":1794376243000,"lat":22.614321,"lon":120.332607,"acc":15.0,"spd":0.1},{"t":1794376248000,"lat":22.614237,"lon":120.332579,"acc":15.0,"spd":0.0},{"t":1794376253000,"lat":22.614259,"lon":120.332422,"acc":15.0,"spd":0.0},{"t":1794376258000,"lat":22.61407,"lon":120.332351,"acc":15.0,"spd":0.0},{"t":1794376263000,"lat":22.614245,"lon":120.332624,"acc":15.0,"spd":0.3},{"t":1794376268000,"lat":22.614061,"lon":120.332594,"acc":15.0,"spd":0.2},{"t":1794376273000,"lat":22.614353,"lon":120.332617,"acc":15.0,"spd":0.0},{"t":1794376278000,"lat":22.614295,"lon":120.332633,"acc":15.0,"spd":0.2},{"t":1794376283000,"lat":22.614371,"lon":120.332438,"acc":15.0,"spd":0.4},{"t":1794376288000,"lat":22.614123,"lon":120.332664,"acc":15.0,"spd":0.0},{"t":1794376293000,"lat":22.614065,"lon":120.332619,"acc":15.0,"spd":0.3},{"t":1794376298000,"lat":22.614251,"lon":120.332298,"acc":15.0,"spd":0.2},{"t":1794376303000,"lat":22.614277,"lon":120.332462,"acc":15.0,"spd":0.5},{"t":1794376308000,"lat":22.614322,"lon":120.332572,"acc":15.0,"spd":0.0},{"t":1794376313000,"lat":22.614201,"lon":120.332467,"acc":15.0,"spd":0.0},{"t":1794376318000,"lat":22.614117,"lon":120.332732,"acc":15.0,"spd":0.6},{"t":1794376323000,"lat":22.614326,"lon":120.332466,"acc":15.0,"spd":0.0},{"t":1794376328000,"lat":22.614173,"lon":120.332793,"acc":15.0,"spd":0.0},{"t":1794376333000,"lat":22.614251,"lon":120.332727,"acc":15.0,"spd":0.0},{"t":1794376338000,"lat":22.61437,"lon":120.332472,"acc":15.0,"spd":0.0},{"t":1794376343000,"lat":22.614138,"lon":120.332377,"acc":15.0,"spd":0.0},{"t":1794376348000,"lat":22.61449,"lon":120.332534,"acc":15.0,"spd":0.2},{"t":1794376353000,"lat":22.614362,"lon":120.332504,"acc":15.0,"spd":0.0},{"t":1794376358000,"lat":22.61411,"lon":120.332529,"acc":15.0,"spd":0.0},{"t":1794376363000,"lat":22.614303,"lon":120.332586,"acc":15.0,"spd":0.0},{"t":1794376368000,"lat":22.614227,"lon":120.332537,"acc":15.0,"spd":0.1},{"t":1794376373000,"lat":22.614101,"lon":120.332513,"acc":15.0,"spd":0.9},{"t":1794376378000,"lat":22.614666,"lon":120.332368,"acc":15.0,"spd":8.0},{"t":1794376383000,"lat":22.615043,"lon":120.332597,"acc":15.0,"spd":8.0},{"t":1794376388000,"lat":22.615542,"lon":120.332387,"acc":15.0,"spd":8.4},{"t":1794376393000,"lat":22.615753,"lon":120.332322,"acc":15.0,"spd":6.5},{"t":1794376398000,"lat":22.615951,"lon":120.332462,"acc":15.0,"spd":0.0},{"t":1794376403000,"lat":22.615839,"lon":120.332596,"acc":15.0,"spd":0.0},{"t":1794376408000,"lat":22.615881,"lon":120.332469,"acc":15.0,"spd":0.4},{"t":1794376413000,"lat":22.615726,"lon":120.332174,"acc":15.0,"spd":0.5},{"t":1794376418000,"lat":22.615874,"lon":120.332414,"acc":15.0,"spd":0.1},{"t":1794376423000,"lat":22.615728,"lon":120.332305,"acc":15.0,"spd":0.3},{"t":1794376428000,"lat":22.615773,"lon":120.332462,"acc":15.0,"spd":0.4},{"t":1794376433000,"lat":22.615807,"lon":120.332373,"acc":15.0,"spd":0.2},{"t":1794376438000,"lat":22.616017,"lon":120.332439,"acc":15.0,"spd":0.0},{"t":1794376443000,"lat":22.615991,"lon":120.332354,"acc":15.0,"spd":0.3},{"t":1794376448000,"lat":22.61594,"lon":120.332341,"acc":15.0,"spd":0.0},{"t":1794376453000,"lat":22.616154,"lon":120.332365,"acc":15.0,"spd":2.1},{"t":1794376458000,"lat":22.616466,"lon":120.332055,"acc":15.0,"spd":10.5},{"t":1794376463000,"lat":22.616891,"lon":120.3323,"acc":15.0,"spd":10.5},{"t":1794376468000,"lat":22.617413,"lon":120.332199,"acc":15.0,"spd":10.1},{"t":1794376473000,"lat":22.617881,"lon":120.331964,"acc":15.0,"spd":9.7},{"t":1794376478000,"lat":22.618296,"lon":120.332048,"acc":15.0,"spd":10.6},{"t":1794376483000,"lat":22.618718,"lon":120.331776,"acc":15.0,"spd":10.7},{"t":1794376488000,"lat":22.619491,"lon":120.331825,"acc":15.0,"spd":9.5},{"t":1794376493000,"lat":22.619721,"lon":120.331677,"acc":15.0,"spd":8.3},{"t":1794376498000,"lat":22.619893,"lon":120.331696,"acc":15.0,"spd":7.4},{"t":1794376503000,"lat":22.620108,"lon":120.331726,"acc":15.0,"spd":7.3},{"t":1794376508000,"lat":22.620579,"lon":120.33188,"acc":15.0,"spd":8.1},{"t":1794376513000,"lat":22.620913,"lon":120.331745,"acc":15.0,"spd":7.0},{"t":1794376518000,"lat":22.621029,"lon":120.331711,"acc":15.0,"spd":0.4},{"t":1794376523000,"lat":22.621164,"lon":120.331654,"acc":15.0,"spd":0.5},{"t":1794376528000,"lat":22.621026,"lon":120.331738,"acc":15.0,"spd":1.5},{"t":1794376533000,"lat":22.621058,"lon":120.331718,"acc":15.0,"spd":2.2},{"t":1794376538000,"lat":22.621517,"lon":120.331805,"acc":15.0,"spd":4.7},{"t":1794376543000,"lat":22.621643,"lon":120.331359,"acc":15.0,"spd":4.7},{"t":1794376548000,"lat":22.62175,"lon":120.331443,"acc":15.0,"spd":5.3},{"t":1794376553000,"lat":22.621953,"lon":120.331389,"acc":15.0,"spd":4.2},{"t":1794376558000,"lat":22.622217,"lon":120.331455,"acc":15.0,"spd":4.9},{"t":1794376563000,"lat":22.622274,"lon":120.331429,"acc":15.0,"spd":4.5},{"t":1794376568000,"lat":22.622454,"lon":120.331129,"acc":15.0,"spd":4.5},{"t":1794376573000,"lat":22.622522,"lon":120.331082,"acc":15.0,"spd":4.4},{"t":1794376578000,"lat":22.622386,"lon":120.330804,"acc":15.0,"spd":9.2},{"t":1794376583000,"lat":22.622455,"lon":120.330106,"acc":15.0,"spd":9.1},{"t":1794376588000,"lat":22.622365,"lon":120.329919,"acc":15.0,"spd":8.5},{"t":1794376593000,"lat":22.622013,"lon":120.329418,"acc":15.0,"spd":6.8},{"t":1794376598000,"lat":22.622173,"lon":120.329517,"acc":15.0,"spd":0.9},{"t":1794376603000,"lat":22.622213,"lon":120.329236,"acc":15.0,"spd":1.8},{"t":1794376608000,"lat":22.622094,"lon":120.329195,"acc":15.0,"spd":1.8},{"t":1794376613000,"lat":22.622013,"lon":120.329239,"acc":15.0,"spd":1.8},{"t":1794376618000,"lat":22.622053,"lon":120.329042,"acc":15.0,"spd":2.1},{"t":1794376623000,"lat":22.622124,"lon":120.329041,"acc":15.0,"spd":1.5},{"t":1794376628000,"lat":22.62196,"lon":120.328784,"acc":15.0,"spd":1.2},{"t":1794376633000,"lat":22.621876,"lon":120.32875,"acc":15.0,"spd":1.4},{"t":1794376638000,"lat":22.621837,"lon":120.328598,"acc":15.0,"spd":6.0},{"t":1794376643000,"lat":22.621635,"lon":120.328279,"acc":15.0,"spd":9.4},{"t":1794376648000,"lat":22.621635,"lon":120.327906,"acc":15.0,"spd":8.9},{"t":1794376653000,"lat":22.621539,"lon":120.327332,"acc":15.0,"spd":8.6},{"t":1794376658000,"lat":22.621434,"lon":120.327107,"acc":15.0,"spd":6.2},{"t":1794376663000,"lat":22.621174,"lon":120.327082,"acc":15.0,"spd":0.0},{"t":1794376668000,"lat":22.621306,"lon":120.327117,"acc":15.0,"spd":0.0},{"t":1794376673000,"lat":22.621096,"lon":120.32699,"acc":15.0,"spd":0.2},{"t":1794376678000,"lat":22.621573,"lon":120.32684,"acc":15.0,"spd":0.1},{"t":1794376683000,"lat":22.62126,"lon":120.326798,"acc":15.0,"spd":0.0},{"t":1794376688000,"lat":22.621224,"lon":120.326874,"acc":15.0,"spd":0.0},{"t":1794376693000,"lat":22.621464,"lon":120.327288,"acc":15.0,"spd":0.3},{"t":1794376698000,"lat":22.621496,"lon":120.327048,"acc":15.0,"spd":0.5},{"t":1794376703000,"lat":22.621309,"lon":120.327142,"acc":15.0,"spd":0.6},{"t":1794376708000,"lat":22.621424,"lon":120.32706,"acc":15.0,"spd":0.7},{"t":1794376713000,"lat":22.621309,"lon":120.327102,"acc":15.0,"spd":0.2},{"t":1794376718000,"lat":22.621185,"lon":120.326971,"acc":15.0,"spd":3.0},{"t":1794376723000,"lat":22.621119,"lon":120.326914,"acc":15.0,"spd":2.5},{"t":1794376728000,"lat":22.621136,"lon":120.326661,"acc":15.0,"spd":2.9},{"t":1794376733000,"lat":22.621212,"lon":120.326414,"acc":15.0,"spd":3.4},{"t":1794376738000,"lat":22.621156,"lon":120.326432,"acc":15.0,"spd":3.7},{"t":1794376743000,"lat":22.620855,"lon":120.326018,"acc":15.0,"spd":2.3},{"t":1794376748000,"lat":22.620968,"lon":120.326042,"acc":15.0,"spd":3.3},{"t":1794376753000,"lat":22.620979,"lon":120.326048,"acc":15.0,"spd":4.2},{"t":1794376758000,"lat":22.620875,"lon":120.325463,"acc":15.0,"spd":8.1},{"t":1794376763000,"lat":22.620786,"lon":120.324967,"acc":15.0,"spd":8.3},{"t":1794376768000,"lat":22.620531,"lon":120.324692,"acc":15.0,"spd":8.2},{"t":1794376773000,"lat":22.62038,"lon":120.324508,"acc":15.0,"spd":8.2},{"t":1794376778000,"lat":22.620314,"lon":120.32397,"acc":15.0,"spd":8.2},{"t":1794376783000,"lat":22.620295,"lon":120.323645,"acc":15.0,"spd":8.1},{"t":1794376788000,"lat":22.62012,"lon":120.323379,"acc":15.0,"spd":8.6},{"t":1794376793000,"lat":22.620074,"lon":120.323026,"acc":15.0,"spd":3.7},{"t":1794376798000,"lat":22.620076,"lon":120.322924,"acc":15.0,"spd":4.6},{"t":1794376803000,"lat":22.619962,"lon":120.322696,"acc":15.0,"spd":3.9},{"t":1794376808000,"lat":22.619839,"lon":120.322385,"acc":15.0,"spd":4.3},{"t":1794376813000,"lat":22.619991,"lon":120.322359,"acc":15.0,"spd":3.6},{"t":1794376818000,"lat":22.619902,"lon":120.322105,"acc":15.0,"spd":0.8},{"t":1794376823000,"lat":22.619596,"lon":120.322328,"acc":15.0,"spd":0.0},{"t":1794376828000,"lat":22.619912,"lon":120.322401,"acc":15.0,"spd":0.0},{"t":1794376833000,"lat":22.619725,"lon":120.322339,"acc":15.0,"spd":0.8},{"t":1794376838000,"lat":22.619566,"lon":120.322096,"acc":15.0,"spd":3.0},{"t":1794376843000,"lat":22.619848,"lon":120.321845,"acc":15.0,"spd":3.2},{"t":1794376848000,"lat":22.619635,"lon":120.321861,"acc":15.0,"spd":3.5},{"t":1794376853000,"lat":22.61954,"lon":120.321799,"acc":15.0,"spd":4.2},{"t":1794376858000,"lat":22.619339,"lon":120.321328,"acc":15.0,"spd":3.9},{"t":1794376863000,"lat":22.619627,"lon":120.321268,"acc":15.0,"spd":3.8},{"t":1794376868000,"lat":22.619461,"lon":120.321166,"acc":15.0,"spd":3.6},{"t":1794376873000,"lat":22.619263,"lon":120.32122,"acc":15.0,"spd":4.0},{"t":1794376878000,"lat":22.619306,"lon":120.320831,"acc":15.0,"spd":3.8},{"t":1794376883000,"lat":22.619077,"lon":120.320342,"acc":15.0,"spd":6.5},{"t":1794376888000,"lat":22.61905,"lon":120.319964,"acc":15.0,"spd":7.5},{"t":1794376893000,"lat":22.618858,"lon":120.319666,"acc":15.0,"spd":6.3},{"t":1794376898000,"lat":22.618867,"lon":120.319848,"acc":15.0,"spd":2.2},{"t":1794376903000,"lat":22.61894,"lon":120.31947,"acc":15.0,"spd":2.0},{"t":1794376908000,"lat":22.618891,"lon":120.319598,"acc":15.0,"spd":3.6},{"t":1794376913000,"lat":22.619039,"lon":120.319252,"acc":15.0,"spd":2.7},{"t":1794376918000,"lat":22.618643,"lon":120.319199,"acc":15.0,"spd":2.5},{"t":1794376923000,"lat":22.618648,"lon":120.31894,"acc":15.0,"spd":5.1},{"t":1794376928000,"lat":22.618466,"lon":120.318668,"acc":15.0,"spd":5.6},{"t":1794376933000,"lat":22.618524,"lon":120.318541,"acc":15.0,"spd":6.3},{"t":1794376938000,"lat":22.618493,"lon":120.31817,"acc":15.0,"spd":6.2},{"t":1794376943000,"lat":22.618575,"lon":120.317788,"acc":15.0,"spd":6.1},{"t":1794376948000,"lat":22.618506,"lon":120.31756,"acc":15.0,"spd":6.0},{"t":1794376953000,"lat":22.618134,"lon":120.317082,"acc":15.0,"spd":5.4},{"t":1794376958000,"lat":22.617988,"lon":120.317047,"acc":15.0,"spd":2.5},{"t":1794376963000,"lat":22.618018,"lon":120.316861,"acc":15.0,"spd":2.8},{"t":1794376968000,"lat":22.61812,"lon":120.316938,"acc":15.0,"spd":2.5},{"t":1794376973000,"lat":22.618202,"lon":120.316501,"acc":15.0,"spd":3.0},{"t":1794376978000,"lat":22.618073,"lon":120.316506,"acc":15.0,"spd":2.5},{"t":1794376983000,"lat":22.618086,"lon":120.316591,"acc":15.0,"spd":1.3},{"t":1794376988000,"lat":22.617988,"lon":120.316325,"acc":15.0,"spd":0.9},{"t":1794376993000,"lat":22.618078,"lon":120.316448,"acc":15.0,"spd":1.5},{"t":1794376998000,"lat":22.617971,"lon":120.316496,"acc":15.0,"spd":1.3},{"t":1794377003000,"lat":22.618065,"lon":120.316402,"acc":15.0,"spd":0.9},{"t":1794377008000,"lat":22.618026,"lon":120.316387,"acc":15.0,"spd":0.7},{"t":1794377013000,"lat":22.617973,"lon":120.316279,"acc":15.0,"spd":1.9},{"t":1794377018000,"lat":22.617768,"lon":120.316042,"acc":15.0,"spd":5.8},{"t":1794377023000,"lat":22.61769,"lon":120.316048,"acc":15.0,"spd":6.4},{"t":1794377028000,"lat":22.617595,"lon":120.315612,"acc":15.0,"spd":5.6},{"t":1794377033000,"lat":22.617656,"lon":120.315132,"acc":15.0,"spd":5.8},{"t":1794377038000,"lat":22.617373,"lon":120.315056,"acc":15.0,"spd":5.2},{"t":1794377043000,"lat":22.617357,"lon":120.314804,"acc":15.0,"spd":5.5},{"t":1794377048000,"lat":22.617509,"lon":120.314449,"acc":15.0,"spd":6.3},{"t":1794377053000,"lat":22.617187,"lon":120.314157,"acc":15.0,"spd":11.4},{"t":1794377058000,"lat":22.616898,"lon":120.313475,"acc":15.0,"spd":11.6},{"t":1794377063000,"lat":22.616815,"lon":120.312975,"acc":15.0,"spd":10.6},{"t":1794377068000,"lat":22.616388,"lon":120.31231,"acc":15.0,"spd":11.6},{"t":1794377073000,"lat":22.616405,"lon":120.311858,"acc":15.0,"spd":11.0},{"t":1794377078000,"lat":22.616256,"lon":120.311554,"acc":15.0,"spd":12.5},{"t":1794377083000,"lat":22.615851,"lon":120.310896,"acc":15.0,"spd":12.3},{"t":1794377088000,"lat":22.615759,"lon":120.310239,"acc":15.0,"spd":12.5},{"t":1794377093000,"lat":22.615664,"lon":120.309833,"acc":15.0,"spd":12.1},{"t":1794377098000,"lat":22.615353,"lon":120.309427,"acc":15.0,"spd":6.5},{"t":1794377103000,"lat":22.615493,"lon":120.309349,"acc":15.0,"spd":2.4},{"t":1794377108000,"lat":22.615645,"lon":120.309414,"acc":15.0,"spd":1.8},{"t":1794377113000,"lat":22.615219,"lon":120.309147,"acc":15.0,"spd":2.1},{"t":1794377118000,"lat":22.615562,"lon":120.309021,"acc":15.0,"spd":2.3},{"t":1794377123000,"lat":22.615372,"lon":120.309042,"acc":15.0,"spd":1.7},{"t":1794377128000,"lat":22.615352,"lon":120.308974,"acc":15.0,"spd":2.0},{"t":1794377133000,"lat":22.615403,"lon":120.308827,"acc":15.0,"spd":1.6},{"t":1794377138000,"lat":22.615277,"lon":120.308705,"acc":15.0,"spd":0.0},{"t":1794377143000,"lat":22.615266,"lon":120.308589,"acc":15.0,"spd":0.2},{"t":1794377148000,"lat":22.615432,"lon":120.30883,"acc":15.0,"spd":0.0},{"t":1794377153000,"lat":22.615545,"lon":120.308708,"acc":15.0,"spd":0.3},{"t":1794377158000,"lat":22.615412,"lon":120.308542,"acc":15.0,"spd":4.7},{"t":1794377163000,"lat":22.615275,"lon":120.308281,"acc":15.0,"spd":4.1},{"t":1794377168000,"lat":22.61502,"lon":120.30794,"acc":15.0,"spd":4.3},{"t":1794377173000,"lat":22.615134,"lon":120.307847,"acc":15.0,"spd":5.0},{"t":1794377178000,"lat":22.61503,"lon":120.307535,"acc":15.0,"spd":4.6},{"t":1794377183000,"lat":22.615224,"lon":120.307726,"acc":15.0,"spd":5.1},{"t":1794377188000,"lat":22.614922,"lon":120.307317,"acc":15.0,"spd":4.5},{"t":1794377193000,"lat":22.614946,"lon":120.307223,"acc":15.0,"spd":5.2},{"t":1794377198000,"lat":22.614632,"lon":120.306769,"acc":15.0,"spd":5.3},{"t":1794377203000,"lat":22.614728,"lon":120.306633,"acc":15.0,"spd":6.3},{"t":1794377208000,"lat":22.614438,"lon":120.306167,"acc":15.0,"spd":6.9},{"t":1794377213000,"lat":22.614409,"lon":120.305951,"acc":15.0,"spd":6.2},{"t":1794377218000,"lat":22.614543,"lon":120.306109,"acc":15.0,"spd":1.4},{"t":1794377223000,"lat":22.6145,"lon":120.305692,"acc":15.0,"spd":1.6},{"t":1794377228000,"lat":22.614284,"lon":120.305879,"acc":15.0,"spd":1.1},{"t":1794377233000,"lat":22.614479,"lon":120.305674,"acc":15.0,"spd":2.1},{"t":1794377238000,"lat":22.614096,"lon":120.305833,"acc":15.0,"spd":1.1},{"t":1794377243000,"lat":22.614314,"lon":120.305405,"acc":15.0,"spd":1.5},{"t":1794377248000,"lat":22.61445,"lon":120.305644,"acc":15.0,"spd":1.4},{"t":1794377253000,"lat":22.614283,"lon":120.305381,"acc":15.0,"spd":1.8},{"t":1794377258000,"lat":22.614175,"lon":120.305261,"acc":15.0,"spd":2.8},{"t":1794377263000,"lat":22.614184,"lon":120.305318,"acc":15.0,"spd":1.9},{"t":1794377268000,"lat":22.614384,"lon":120.305203,"acc":15.0,"spd":3.3},{"t":1794377273000,"lat":22.613983,"lon":120.305029,"acc":15.0,"spd":2.2},{"t":1794377278000,"lat":22.614111,"lon":120.305023,"acc":15.0,"spd":0.0},{"t":1794377283000,"lat":22.614139,"lon":120.304938,"acc":15.0,"spd":0.0},{"t":1794377288000,"lat":22.614072,"lon":120.3048,"acc":15.0,"spd":0.8},{"t":1794377293000,"lat":22.614062,"lon":120.305071,"acc":15.0,"spd":0.0},{"t":1794377298000,"lat":22.614044,"lon":120.304692,"acc":15.0,"spd":0.1},{"t":1794377303000,"lat":22.614056,"lon":120.305052,"acc":15.0,"spd":0.5},{"t":1794377308000,"lat":22.613946,"lon":120.304758,"acc":15.0,"spd":0.0},{"t":1794377313000,"lat":22.614107,"lon":120.305051,"acc":15.0,"spd":0.1},{"t":1794377318000,"lat":22.614156,"lon":120.305085,"acc":15.0,"spd":0.0},{"t":1794377323000,"lat":22.614045,"lon":120.304745,"acc":15.0,"spd":0.0},{"t":1794377328000,"lat":22.613956,"lon":120.304879,"acc":15.0,"spd":0.0},{"t":1794377333000,"lat":22.614022,"lon":120.304957,"acc":15.0,"spd":1.6},{"t":1794377338000,"lat":22.613795,"lon":120.304424,"acc":15.0,"spd":5.0},{"t":1794377343000,"lat":22.614056,"lon":120.304242,"acc":15.0,"spd":5.1},{"t":1794377348000,"lat":22.613845,"lon":120.30418,"acc":15.0,"spd":5.4},{"t":1794377353000,"lat":22.613671,"lon":120.304087,"acc":15.0,"spd":5.9},{"t":1794377358000,"lat":22.613767,"lon":120.303792,"acc":15.0,"spd":4.7},{"t":1794377363000,"lat":22.613367,"lon":120.303476,"acc":15.0,"spd":5.1},{"t":1794377368000,"lat":22.613521,"lon":120.303344,"acc":15.0,"spd":5.0},{"t":1794377373000,"lat":22.613417,"lon":120.303057,"acc":15.0,"spd":5.3},{"t":1794377378000,"lat":22.613455,"lon":120.303156,"acc":15.0,"spd":1.1},{"t":1794377383000,"lat":22.613339,"lon":120.303356,"acc":15.0,"spd":0.3},{"t":1794377388000,"lat":22.61351,"lon":120.303118,"acc":15.0,"spd":0.0},{"t":1794377393000,"lat":22.613502,"lon":120.303028,"acc":15.0,"spd":0.2},{"t":1794377398000,"lat":22.613569,"lon":120.303127,"acc":15.0,"spd":0.0},{"t":1794377403000,"lat":22.613693,"lon":120.303075,"acc":15.0,"spd":0.0},{"t":1794377408000,"lat":22.613445,"lon":120.302995,"acc":15.0,"spd":0.5}],"waitingFixes":[{"t":1794376163000,"lat":22.614265,"lon":120.332554,"acc":25.0,"spd":0.4},{"t":1794376168000,"lat":22.613849,"lon":120.332453,"acc":25.0,"spd":0.4},{"t":1794376173000,"lat":22.614412,"lon":120.332561,"acc":25.0,"spd":0.2},{"t":1794376178000,"lat":22.614207,"lon":120.332491,"acc":25.0,"spd":0.4},{"t":1794376183000,"lat":22.614403,"lon":120.33286,"acc":25.0,"spd":0.4},{"t":1794376188000,"lat":22.614506,"lon":120.332349,"acc":25.0,"spd":0.3},{"t":1794376193000,"lat":22.614432,"lon":120.33266,"acc":25.0,"spd":0.6},{"t":1794376198000,"lat":22.614136,"lon":120.33242,"acc":25.0,"spd":0.1},{"t":1794376203000,"lat":22.614364,"lon":120.332321,"acc":25.0,"spd":0.5},{"t":1794376208000,"lat":22.614202,"lon":120.332599,"acc":25.0,"spd":0.2},{"t":1794376213000,"lat":22.614313,"lon":120.332725,"acc":25.0,"spd":0.0},{"t":1794376218000,"lat":22.61431,"lon":120.3325,"acc":25.0,"spd":0.2},{"t":1794376223000,"lat":22.614423,"lon":120.333277,"acc":25.0,"spd":0.1},{"t":1794376228000,"lat":22.614338,"lon":120.333116,"acc":25.0,"spd":0.3},{"t":1794376233000,"lat":22.614172,"lon":120.332793,"acc":25.0,"spd":0.4},{"t":1794376238000,"lat":22.614181,"lon":120.332813,"acc":25.0,"spd":0.2},{"t":1794376243000,"lat":22.614242,"lon":120.332377,"acc":25.0,"spd":0.2},{"t":1794376248000,"lat":22.614312,"lon":120.332795,"acc":25.0,"spd":0.1},{"t":1794376253000,"lat":22.614333,"lon":120.332317,"acc":25.0,"spd":0.3},{"t":1794376258000,"lat":22.614287,"lon":120.332509,"acc":25.0,"spd":0.1},{"t":1794376263000,"lat":22.614358,"lon":120.332429,"acc":25.0,"spd":0.5},{"t":1794376268000,"lat":22.614284,"lon":120.332567,"acc":25.0,"spd":0.2},{"t":1794376273000,"lat":22.614362,"lon":120.332455,"acc":25.0,"spd":0.1},{"t":1794376278000,"lat":22.61429,"lon":120.332491,"acc":25.0,"spd":0.1},{"t":1794376283000,"lat":22.614145,"lon":120.332359,"acc":25.0,"spd":0.2},{"t":1794376288000,"lat":22.614394,"lon":120.332736,"acc":25.0,"spd":0.1},{"t":1794376293000,"lat":22.614297,"lon":120.332227,"acc":25.0,"spd":0.1},{"t":1794376298000,"lat":22.614614,"lon":120.332772,"acc":25.0,"spd":0.3},{"t":1794376303000,"lat":22.614373,"lon":120.332829,"acc":25.0,"spd":0.2},{"t":1794376308000,"lat":22.614311,"lon":120.332495,"acc":25.0,"spd":0.0},{"t":1794376313000,"lat":22.614502,"lon":120.332465,"acc":25.0,"spd":0.3},{"t":1794376318000,"lat":22.614435,"lon":120.332465,"acc":25.0,"spd":0.3},{"t":1794376323000,"lat":22.614416,"lon":120.332472,"acc":25.0,"spd":0.1},{"t":1794376328000,"lat":22.614294,"lon":120.332753,"acc":25.0,"spd":0.4},{"t":1794376333000,"lat":22.614259,"lon":120.332789,"acc":25.0,"spd":0.1},{"t":1794376338000,"lat":22.614201,"lon":120.332431,"acc":25.0,"spd":0.0},{"t":1794376343000,"lat":22.614486,"lon":120.332485,"acc":25.0,"spd":0.0},{"t":1794376348000,"lat":22.614479,"lon":120.332595,"acc":25.0,"spd":0.2},{"t":1794376353000,"lat":22.614209,"lon":120.332654,"acc":25.0,"spd":0.4},{"t":1794376358000,"lat":22.614281,"lon":120.33247,"acc":25.0,"spd":0.7},{"t":1794376363000,"lat":22.614312,"lon":120.332304,"acc":25.0,"spd":0.3},{"t":1794376368000,"lat":22.614277,"lon":120.332635,"acc":25.0,"spd":0.0},{"t":1794376373000,"lat":22.614263,"lon":120.332607,"acc":25.0,"spd":0.4},{"t":1794376378000,"lat":22.614164,"lon":120.332519,"acc":25.0,"spd":0.4},{"t":1794376383000,"lat":22.614227,"lon":120.332492,"acc":25.0,"spd":0.3},{"t":1794376388000,"lat":22.61427,"lon":120.3326,"acc":25.0,"spd":0.1},{"t":1794376393000,"lat":22.614135,"lon":120.332779,"acc":25.0,"spd":0.1},{"t":1794376398000,"lat":22.614383,"lon":120.33262,"acc":25.0,"spd":0.2},{"t":1794376403000,"lat":22.614305,"lon":120.332372,"acc":25.0,"spd":0.1},{"t":1794376408000,"lat":22.614257,"lon":120.332634,"acc":25.0,"spd":0.5},{"t":1794376413000,"lat":22.614146,"lon":120.332627,"acc":25.0,"spd":0.0},{"t":1794376418000,"lat":22.61449,"lon":120.332775,"acc":25.0,"spd":0.2},{"t":1794376423000,"lat":22.614329,"lon":120.333158,"acc":25.0,"spd":0.1},{"t":1794376428000,"lat":22.614243,"lon":120.332981,"acc":25.0,"spd":0.5},{"t":1794376433000,"lat":22.614319,"lon":120.332716,"acc":25.0,"spd":0.1},{"t":1794376438000,"lat":22.614251,"lon":120.332297,"acc":25.0,"spd":0.1},{"t":1794376443000,"lat":22.614079,"lon":120.332726,"acc":25.0,"spd":0.3},{"t":1794376448000,"lat":22.614077,"lon":120.33249,"acc":25.0,"spd":0.0},{"t":1794376453000,"lat":22.614236,"lon":120.332382,"acc":25.0,"spd":0.3},{"t":1794376458000,"lat":22.614403,"lon":120.3327,"acc":25.0,"spd":0.2},{"t":1794376463000,"lat":22.614249,"lon":120.332632,"acc":25.0,"spd":0.2},{"t":1794376468000,"lat":22.614429,"lon":120.332698,"acc":25.0,"spd":0.1},{"t":1794376473000,"lat":22.614312,"lon":120.332481,"acc":25.0,"spd":0.5},{"t":1794376478000,"lat":22.614296,"lon":120.332439,"acc":25.0,"spd":0.4},{"t":1794376483000,"lat":22.614437,"lon":120.332732,"acc":25.0,"spd":0.3},{"t":1794376488000,"lat":22.614393,"lon":120.332484,"acc":25.0,"spd":0.0},{"t":1794376493000,"lat":22.614391,"lon":120.332555,"acc":25.0,"spd":0.3},{"t":1794376498000,"lat":22.614244,"lon":120.332605,"acc":25.0,"spd":0.2},{"t":1794376503000,"lat":22.614292,"lon":120.33241,"acc":25.0,"spd":0.2},{"t":1794376508000,"lat":22.61438,"lon":120.332404,"acc":25.0,"spd":0.2},{"t":1794376513000,"lat":22.614246,"lon":120.33246,"acc":25.0,"spd":0.3},{"t":1794376518000,"lat":22.614188,"lon":120.332523,"acc":25.0,"spd":0.3},{"t":1794376523000,"lat":22.614422,"lon":120.332393,"acc":25.0,"spd":0.2},{"t":1794376528000,"lat":22.614239,"lon":120.332661,"acc":25.0,"spd":0.4},{"t":1794376533000,"lat":22.614412,"lon":120.332466,"acc":25.0,"spd":0.2},{"t":1794376538000,"lat":22.614019,"lon":120.33247,"acc":25.0,"spd":0.3},{"t":1794376543000,"lat":22.614532,"lon":120.332571,"acc":25.0,"spd":0.1},{"t":1794376548000,"lat":22.614453,"lon":120.332271,"acc":25.0,"spd":0.3},{"t":1794376553000,"lat":22.614257,"lon":120.332629,"acc":25.0,"spd":0.1},{"t":1794376558000,"lat":22.614545,"lon":120.332258,"acc":25.0,"spd":0.3},{"t":1794376563000,"lat":22.614371,"lon":120.332463,"acc":25.0,"spd":0.2},{"t":1794376568000,"lat":22.614315,"lon":120.332384,"acc":25.0,"spd":0.0},{"t":1794376573000,"lat":22.614451,"lon":120.332555,"acc":25.0,"spd":0.1},{"t":1794376578000,"lat":22.614135,"lon":120.332437,"acc":25.0,"spd":0.3},{"t":1794376583000,"lat":22.614348,"lon":120.332388,"acc":25.0,"spd":0.0},{"t":1794376588000,"lat":22.614075,"lon":120.332555,"acc":25.0,"spd":0.3},{"t":1794376593000,"lat":22.614332,"lon":120.332626,"acc":25.0,"spd":0.3},{"t":1794376598000,"lat":22.614164,"lon":120.33246,"acc":25.0,"spd":0.3},{"t":1794376603000,"lat":22.614258,"lon":120.332442,"acc":25.0,"spd":0.1},{"t":1794376608000,"lat":22.614366,"lon":120.333139,"acc":25.0,"spd":0.2},{"t":1794376613000,"lat":22.614347,"lon":120.332632,"acc":25.0,"spd":0.3},{"t":1794376618000,"lat":22.614478,"lon":120.332466,"acc":25.0,"spd":0.3},{"t":1794376623000,"lat":22.614297,"lon":120.332659,"acc":25.0,"spd":0.0},{"t":1794376628000,"lat":22.614289,"lon":120.332545,"acc":25.0,"spd":0.1},{"t":1794376633000,"lat":22.614264,"lon":120.332364,"acc":25.0,"spd":0.0},{"t":1794376638000,"lat":22.614503,"lon":120.332727,"acc":25.0,"spd":0.1},{"t":1794376643000,"lat":22.614204,"lon":120.332373,"acc":25.0,"spd":0.6},{"t":1794376648000,"lat":22.61412,"lon":120.332675,"acc":25.0,"spd":0.1},{"t":1794376653000,"lat":22.614321,"lon":120.332468,"acc":25.0,"spd":0.2},{"t":1794376658000,"lat":22.614211,"lon":120.332518,"acc":25.0,"spd":0.2},{"t":1794376663000,"lat":22.614242,"lon":120.332603,"acc":25.0,"spd":0.0},{"t":1794376668000,"lat":22.614463,"lon":120.332673,"acc":25.0,"spd":0.1},{"t":1794376673000,"lat":22.614574,"lon":120.332316,"acc":25.0,"spd":0.7},{"t":1794376678000,"lat":22.614473,"lon":120.332586,"acc":25.0,"spd":0.8},{"t":1794376683000,"lat":22.614519,"lon":120.332521,"acc":25.0,"spd":0.5},{"t":1794376688000,"lat":22.614267,"lon":120.332344,"acc":25.0,"spd":0.1},{"t":1794376693000,"lat":22.614214,"lon":120.332611,"acc":25.0,"spd":0.3},{"t":1794376698000,"lat":22.614425,"lon":120.332542,"acc":25.0,"spd":0.2},{"t":1794376703000,"lat":22.614219,"lon":120.332524,"acc":25.0,"spd":0.0},{"t":1794376708000,"lat":22.614214,"lon":120.332585,"acc":25.0,"spd":0.3},{"t":1794376713000,"lat":22.614197,"lon":120.332467,"acc":25.0,"spd":0.2},{"t":1794376718000,"lat":22.61424,"lon":120.332351,"acc":25.0,"spd":0.0},{"t":1794376723000,"lat":22.61441,"lon":120.332488,"acc":25.0,"spd":0.1},{"t":1794376728000,"lat":22.614032,"lon":120.332645,"acc":25.0,"spd":0.1},{"t":1794376733000,"lat":22.614416,"lon":120.332423,"acc":25.0,"spd":0.1},{"t":1794376738000,"lat":22.614273,"lon":120.332638,"acc":25.0,"spd":0.3},{"t":1794376743000,"lat":22.614111,"lon":120.332515,"acc":25.0,"spd":0.0},{"t":1794376748000,"lat":22.614507,"lon":120.332521,"acc":25.0,"spd":0.0},{"t":1794376753000,"lat":22.613797,"lon":120.332737,"acc":25.0,"spd":0.6},{"t":1794376758000,"lat":22.614311,"lon":120.332554,"acc":25.0,"spd":0.1},{"t":1794376763000,"lat":22.614312,"lon":120.332313,"acc":25.0,"spd":0.1},{"t":1794376768000,"lat":22.614416,"lon":120.332686,"acc":25.0,"spd":0.3},{"t":1794376773000,"lat":22.614381,"lon":120.332551,"acc":25.0,"spd":0.4},{"t":1794376778000,"lat":22.614535,"lon":120.332556,"acc":25.0,"spd":0.4},{"t":1794376783000,"lat":22.614166,"lon":120.332586,"acc":25.0,"spd":0.3},{"t":1794376788000,"lat":22.614214,"lon":120.331676,"acc":25.0,"spd":0.1},{"t":1794376793000,"lat":22.614082,"lon":120.332173,"acc":25.0,"spd":0.6},{"t":1794376798000,"lat":22.614478,"lon":120.332358,"acc":25.0,"spd":0.2},{"t":1794376803000,"lat":22.614323,"lon":120.332585,"acc":25.0,"spd":0.3},{"t":1794376808000,"lat":22.614303,"lon":120.332553,"acc":25.0,"spd":0.1},{"t":1794376813000,"lat":22.614116,"lon":120.332523,"acc":25.0,"spd":0.2},{"t":1794376818000,"lat":22.614234,"lon":120.332549,"acc":25.0,"spd":0.1},{"t":1794376823000,"lat":22.614459,"lon":120.332404,"acc":25.0,"spd":0.2},{"t":1794376828000,"lat":22.614149,"lon":120.33229,"acc":25.0,"spd":0.5},{"t":1794376833000,"lat":22.614332,"lon":120.33224,"acc":25.0,"spd":0.3},{"t":1794376838000,"lat":22.614233,"lon":120.332443,"acc":25.0,"spd":0.2},{"t":1794376843000,"lat":22.614176,"lon":120.332572,"acc":25.0,"spd":0.4},{"t":1794376848000,"lat":22.614259,"lon":120.332387,"acc":25.0,"spd":0.2},{"t":1794376853000,"lat":22.614183,"lon":120.332346,"acc":25.0,"spd":0.6},{"t":1794376858000,"lat":22.614429,"lon":120.33267,"acc":25.0,"spd":0.2},{"t":1794376863000,"lat":22.614408,"lon":120.332689,"acc":25.0,"spd":0.2},{"t":1794376868000,"lat":22.613993,"lon":120.33268,"acc":25.0,"spd":0.1},{"t":1794376873000,"lat":22.614185,"lon":120.332569,"acc":25.0,"spd":0.3},{"t":1794376878000,"lat":22.614418,"lon":120.332645,"acc":25.0,"spd":0.1},{"t":1794376883000,"lat":22.614125,"lon":120.332427,"acc":25.0,"spd":0.2},{"t":1794376888000,"lat":22.61446,"lon":120.332579,"acc":25.0,"spd":0.0},{"t":1794376893000,"lat":22.614183,"lon":120.332449,"acc":25.0,"spd":0.0},{"t":1794376898000,"lat":22.614293,"lon":120.332527,"acc":25.0,"spd":0.3},{"t":1794376903000,"lat":22.614301,"lon":120.332341,"acc":25.0,"spd":0.8},{"t":1794376908000,"lat":22.614205,"lon":120.332641,"acc":25.0,"spd":0.2},{"t":1794376913000,"lat":22.614061,"lon":120.332432,"acc":25.0,"spd":0.2},{"t":1794376918000,"lat":22.614551,"lon":120.332162,"acc":25.0,"spd":0.1},{"t":1794376923000,"lat":22.61425,"lon":120.332616,"acc":25.0,"spd":0.7},{"t":1794376928000,"lat":22.614267,"lon":120.332542,"acc":25.0,"spd":0.1},{"t":1794376933000,"lat":22.614157,"lon":120.332388,"acc":25.0,"spd":0.5},{"t":1794376938000,"lat":22.614393,"lon":120.332065,"acc":25.0,"spd":0.2},{"t":1794376943000,"lat":22.614214,"lon":120.332314,"acc":25.0,"spd":0.0},{"t":1794376948000,"lat":22.6143,"lon":120.332234,"acc":25.0,"spd":0.1},{"t":1794376953000,"lat":22.614491,"lon":120.332389,"acc":25.0,"spd":0.3},{"t":1794376958000,"lat":22.614407,"lon":120.33274,"acc":25.0,"spd":0.2},{"t":1794376963000,"lat":22.614126,"lon":120.332467,"acc":25.0,"spd":0.1},{"t":1794376968000,"lat":22.614273,"lon":120.332406,"acc":25.0,"spd":0.2},{"t":1794376973000,"lat":22.61401,"lon":120.332656,"acc":25.0,"spd":0.2},{"t":1794376978000,"lat":22.61403,"lon":120.332687,"acc":25.0,"spd":0.2},{"t":1794376983000,"lat":22.614474,"lon":120.332703,"acc":25.0,"spd":0.1},{"t":1794376988000,"lat":22.614281,"lon":120.332587,"acc":25.0,"spd":0.1},{"t":1794376993000,"lat":22.614267,"lon":120.333658,"acc":25.0,"spd":0.3},{"t":1794376998000,"lat":22.6145,"lon":120.332601,"acc":25.0,"spd":0.4},{"t":1794377003000,"lat":22.614569,"lon":120.332696,"acc":25.0,"spd":0.3},{"t":1794377008000,"lat":22.61448,"lon":120.332605,"acc":25.0,"spd":0.3},{"t":1794377013000,"lat":22.614215,"lon":120.332357,"acc":25.0,"spd":0.4},{"t":1794377018000,"lat":22.614149,"lon":120.332872,"acc":25.0,"spd":0.1},{"t":1794377023000,"lat":22.614131,"lon":120.332491,"acc":25.0,"spd":0.0},{"t":1794377028000,"lat":22.614377,"lon":120.332492,"acc":25.0,"spd":0.3},{"t":1794377033000,"lat":22.614364,"lon":120.332728,"acc":25.0,"spd":0.5},{"t":1794377038000,"lat":22.614292,"lon":120.332544,"acc":25.0,"spd":0.3},{"t":1794377043000,"lat":22.614153,"lon":120.332627,"acc":25.0,"spd":0.4},{"t":1794377048000,"lat":22.614353,"lon":120.332517,"acc":25.0,"spd":0.0},{"t":1794377053000,"lat":22.614356,"lon":120.332693,"acc":25.0,"spd":0.4},{"t":1794377058000,"lat":22.614223,"lon":120.33251,"acc":25.0,"spd":0.1},{"t":1794377063000,"lat":22.614053,"lon":120.332665,"acc":25.0,"spd":0.1},{"t":1794377068000,"lat":22.614058,"lon":120.332709,"acc":25.0,"spd":0.0},{"t":1794377073000,"lat":22.614301,"lon":120.332313,"acc":25.0,"spd":0.1},{"t":1794377078000,"lat":22.614166,"lon":120.332339,"acc":25.0,"spd":0.4},{"t":1794377083000,"lat":22.614199,"lon":120.332513,"acc":25.0,"spd":0.1},{"t":1794377088000,"lat":22.61415,"lon":120.332792,"acc":25.0,"spd":0.2},{"t":1794377093000,"lat":22.614277,"lon":120.333426,"acc":25.0,"spd":0.7},{"t":1794377098000,"lat":22.614208,"lon":120.332805,"acc":25.0,"spd":0.3},{"t":1794377103000,"lat":22.61428,"lon":120.332422,"acc":25.0,"spd":0.2},{"t":1794377108000,"lat":22.614307,"lon":120.33278,"acc":25.0,"spd":0.1},{"t":1794377113000,"lat":22.614244,"lon":120.332814,"acc":25.0,"spd":0.6},{"t":1794377118000,"lat":22.614203,"lon":120.332604,"acc":25.0,"spd":0.2},{"t":1794377123000,"lat":22.614326,"lon":120.332551,"acc":25.0,"spd":0.0},{"t":1794377128000,"lat":22.614259,"lon":120.332638,"acc":25.0,"spd":0.3},{"t":1794377133000,"lat":22.614344,"lon":120.332515,"acc":25.0,"spd":0.3},{"t":1794377138000,"lat":22.614167,"lon":120.332614,"acc":25.0,"spd":0.4},{"t":1794377143000,"lat":22.614347,"lon":120.33233,"acc":25.0,"spd":0.1},{"t":1794377148000,"lat":22.614481,"lon":120.332483,"acc":25.0,"spd":0.2},{"t":1794377153000,"lat":22.614462,"lon":120.332351,"acc":25.0,"spd":0.3},{"t":1794377158000,"lat":22.6146,"lon":120.332367,"acc":25.0,"spd":0.1},{"t":1794377163000,"lat":22.614036,"lon":120.332456,"acc":25.0,"spd":0.1},{"t":1794377168000,"lat":22.614388,"lon":120.332393,"acc":25.0,"spd":0.2},{"t":1794377173000,"lat":22.614251,"lon":120.332545,"acc":25.0,"spd":0.2},{"t":1794377178000,"lat":22.614488,"lon":120.332469,"acc":25.0,"spd":0.3},{"t":1794377183000,"lat":22.614426,"lon":120.332574,"acc":25.0,"spd":0.4},{"t":1794377188000,"lat":22.614397,"lon":120.332492,"acc":25.0,"spd":0.4},{"t":1794377193000,"lat":22.614138,"lon":120.332553,"acc":25.0,"spd":0.3},{"t":1794377198000,"lat":22.614695,"lon":120.332458,"acc":25.0,"spd":0.0},{"t":1794377203000,"lat":22.61445,"lon":120.332558,"acc":25.0,"spd":0.4},{"t":1794377208000,"lat":22.614297,"lon":120.332356,"acc":25.0,"spd":0.1},{"t":1794377213000,"lat":22.614152,"lon":120.332358,"acc":25.0,"spd":0.2},{"t":1794377218000,"lat":22.614269,"lon":120.332438,"acc":25.0,"spd":0.1},{"t":1794377223000,"lat":22.614467,"lon":120.332311,"acc":25.0,"spd":0.1},{"t":1794377228000,"lat":22.614453,"lon":120.3324,"acc":25.0,"spd":0.1},{"t":1794377233000,"lat":22.614247,"lon":120.332749,"acc":25.0,"spd":0.5},{"t":1794377238000,"lat":22.614164,"lon":120.332081,"acc":25.0,"spd":0.0},{"t":1794377243000,"lat":22.614179,"lon":120.332338,"acc":25.0,"spd":0.4},{"t":1794377248000,"lat":22.614116,"lon":120.332406,"acc":25.0,"spd":0.2},{"t":1794377253000,"lat":22.614292,"lon":120.332382,"acc":25.0,"spd":0.1},{"t":1794377258000,"lat":22.614289,"lon":120.332836,"acc":25.0,"spd":0.2},{"t":1794377263000,"lat":22.614344,"lon":120.332463,"acc":25.0,"spd":0.3},{"t":1794377268000,"lat":22.614274,"lon":120.332718,"acc":25.0,"spd":0.2},{"t":1794377273000,"lat":22.614353,"lon":120.332436,"acc":25.0,"spd":0.2},{"t":1794377278000,"lat":22.614658,"lon":120.332719,"acc":25.0,"spd":0.1},{"t":1794377283000,"lat":22.614394,"lon":120.332329,"acc":25.0,"spd":0.0},{"t":1794377288000,"lat":22.6142,"lon":120.332712,"acc":25.0,"spd":0.3},{"t":1794377293000,"lat":22.614378,"lon":120.332637,"acc":25.0,"spd":0.2},{"t":1794377298000,"lat":22.61417,"lon":120.332619,"acc":25.0,"spd":0.3},{"t":1794377303000,"lat":22.614339,"lon":120.332487,"acc":25.0,"spd":0.2},{"t":1794377308000,"lat":22.614634,"lon":120.332544,"acc":25.0,"spd":0.1},{"t":1794377313000,"lat":22.614253,"lon":120.332501,"acc":25.0,"spd":0.2},{"t":1794377318000,"lat":22.614236,"lon":120.332203,"acc":25.0,"spd":0.0},{"t":1794377323000,"lat":22.614491,"lon":120.33251,"acc":25.0,"spd":0.2},{"t":1794377328000,"lat":22.614609,"lon":120.332667,"acc":25.0,"spd":0.1},{"t":1794377333000,"lat":22.614186,"lon":120.33231,"acc":25.0,"spd":0.5},{"t":1794377338000,"lat":22.614349,"lon":120.332324,"acc":25.0,"spd":0.2},{"t":1794377343000,"lat":22.614346,"lon":120.332529,"acc":25.0,"spd":0.3},{"t":1794377348000,"lat":22.614361,"lon":120.332552,"acc":25.0,"spd":0.1},{"t":1794377353000,"lat":22.61435,"lon":120.333269,"acc":25.0,"spd":0.6},{"t":1794377358000,"lat":22.614233,"lon":120.3327,"acc":25.0,"spd":0.1},{"t":1794377363000,"lat":22.614329,"lon":120.332485,"acc":25.0,"spd":0.5},{"t":1794377368000,"lat":22.614181,"lon":120.332838,"acc":25.0,"spd":0.2},{"t":1794377373000,"lat":22.614316,"lon":120.332501,"acc":25.0,"spd":0.5},{"t":1794377378000,"lat":22.614232,"lon":120.332439,"acc":25.0,"spd":0.6},{"t":1794377383000,"lat":22.614323,"lon":120.332555,"acc":25.0,"spd":0.1},{"t":1794377388000,"lat":22.614181,"lon":120.332561,"acc":25.0,"spd":0.3},{"t":1794377393000,"lat":22.613992,"lon":120.332448,"acc":25.0,"spd":0.3},{"t":1794377398000,"lat":22.614397,"lon":120.332482,"acc":25.0,"spd":0.5},{"t":1794377403000,"lat":22.614415,"lon":120.332656,"acc":25.0,"spd":0.2},{"t":1794377408000,"lat":22.614482,"lon":120.332409,"acc":25.0,"spd":0.1}],"walkerFixes":[{"t":1794376163000,"lat":22.614433,"lon":120.332475,"acc":15.0,"spd":1.2},{"t":1794376168000,"lat":22.614277,"lon":120.33248,"acc":15.0,"spd":1.5},{"t":1794376173000,"lat":22.61439,"lon":120.33244,"acc":15.0,"spd":1.6},{"t":1794376178000,"lat":22.61446,"lon":120.332384,"acc":15.0,"spd":1.0},{"t":1794376183000,"lat":22.614428,"lon":120.332479,"acc":15.0,"spd":1.2},{"t":1794376188000,"lat":22.614386,"lon":120.332424,"acc":15.0,"spd":1.2},{"t":1794376193000,"lat":22.614908,"lon":120.332465,"acc":15.0,"spd":1.2},{"t":1794376198000,"lat":22.614481,"lon":120.332562,"acc":15.0,"spd":1.4},{"t":1794376203000,"lat":22.614853,"lon":120.33264,"acc":15.0,"spd":1.4},{"t":1794376208000,"lat":22.614706,"lon":120.332709,"acc":15.0,"spd":1.5},{"t":1794376213000,"lat":22.614899,"lon":120.332466,"acc":15.0,"spd":1.4},{"t":1794376218000,"lat":22.615081,"lon":120.332459,"acc":15.0,"spd":1.4},{"t":1794376223000,"lat":22.615134,"lon":120.332324,"acc":15.0,"spd":1.7},{"t":1794376228000,"lat":22.615134,"lon":120.332406,"acc":15.0,"spd":1.2},{"t":1794376233000,"lat":22.615131,"lon":120.332613,"acc":15.0,"spd":1.4},{"t":1794376238000,"lat":22.615225,"lon":120.332456,"acc":15.0,"spd":1.3},{"t":1794376243000,"lat":22.615236,"lon":120.332433,"acc":15.0,"spd":1.3},{"t":1794376248000,"lat":22.615303,"lon":120.332442,"acc":15.0,"spd":1.2},{"t":1794376253000,"lat":22.615442,"lon":120.332575,"acc":15.0,"spd":1.7},{"t":1794376258000,"lat":22.615375,"lon":120.332399,"acc":15.0,"spd":1.6},{"t":1794376263000,"lat":22.615372,"lon":120.332505,"acc":15.0,"spd":1.7},{"t":1794376268000,"lat":22.615635,"lon":120.332577,"acc":15.0,"spd":1.6},{"t":1794376273000,"lat":22.615779,"lon":120.332511,"acc":15.0,"spd":1.3},{"t":1794376278000,"lat":22.615795,"lon":120.332296,"acc":15.0,"spd":1.4},{"t":1794376283000,"lat":22.615701,"lon":120.332333,"acc":15.0,"spd":1.5},{"t":1794376288000,"lat":22.615923,"lon":120.332356,"acc":15.0,"spd":1.6},{"t":1794376293000,"lat":22.615907,"lon":120.332485,"acc":15.0,"spd":1.3},{"t":1794376298000,"lat":22.615948,"lon":120.33234,"acc":15.0,"spd":1.5},{"t":1794376303000,"lat":22.615875,"lon":120.332375,"acc":15.0,"spd":1.1},{"t":1794376308000,"lat":22.6161,"lon":120.33244,"acc":15.0,"spd":1.2},{"t":1794376313000,"lat":22.616043,"lon":120.332323,"acc":15.0,"spd":1.5},{"t":1794376318000,"lat":22.616259,"lon":120.332266,"acc":15.0,"spd":1.5},{"t":1794376323000,"lat":22.616231,"lon":120.332136,"acc":15.0,"spd":1.3},{"t":1794376328000,"lat":22.616443,"lon":120.332342,"acc":15.0,"spd":1.7},{"t":1794376333000,"lat":22.616292,"lon":120.332097,"acc":15.0,"spd":1.6},{"t":1794376338000,"lat":22.616572,"lon":120.332039,"acc":15.0,"spd":1.4},{"t":1794376343000,"lat":22.616625,"lon":120.332361,"acc":15.0,"spd":1.4},{"t":1794376348000,"lat":22.616551,"lon":120.332302,"acc":15.0,"spd":1.1},{"t":1794376353000,"lat":22.616649,"lon":120.332034,"acc":15.0,"spd":1.3},{"t":1794376358000,"lat":22.616617,"lon":120.332362,"acc":15.0,"spd":1.3},{"t":1794376363000,"lat":22.616891,"lon":120.332227,"acc":15.0,"spd":1.0},{"t":1794376368000,"lat":22.616958,"lon":120.332345,"acc":15.0,"spd":1.3},{"t":1794376373000,"lat":22.617065,"lon":120.332117,"acc":15.0,"spd":1.4},{"t":1794376378000,"lat":22.616947,"lon":120.332373,"acc":15.0,"spd":1.1},{"t":1794376383000,"lat":22.617286,"lon":120.332245,"acc":15.0,"spd":1.3},{"t":1794376388000,"lat":22.617243,"lon":120.332292,"acc":15.0,"spd":1.2},{"t":1794376393000,"lat":22.617118,"lon":120.332335,"acc":15.0,"spd":1.5},{"t":1794376398000,"lat":22.617195,"lon":120.332278,"acc":15.0,"spd":1.1},{"t":1794376403000,"lat":22.617334,"lon":120.332144,"acc":15.0,"spd":1.3},{"t":1794376408000,"lat":22.617147,"lon":120.332291,"acc":15.0,"spd":1.4},{"t":1794376413000,"lat":22.617367,"lon":120.332192,"acc":15.0,"spd":1.2},{"t":1794376418000,"lat":22.617398,"lon":120.332211,"acc":15.0,"spd":1.5},{"t":1794376423000,"lat":22.617626,"lon":120.332441,"acc":15.0,"spd":1.4},{"t":1794376428000,"lat":22.617454,"lon":120.332091,"acc":15.0,"spd":1.4},{"t":1794376433000,"lat":22.617704,"lon":120.332267,"acc":15.0,"spd":1.2},{"t":1794376438000,"lat":22.617805,"lon":120.33223,"acc":15.0,"spd":1.1},{"t":1794376443000,"lat":22.617675,"lon":120.332233,"acc":15.0,"spd":1.2},{"t":1794376448000,"lat":22.617798,"lon":120.332145,"acc":15.0,"spd":1.4},{"t":1794376453000,"lat":22.617985,"lon":120.332354,"acc":15.0,"spd":1.6},{"t":1794376458000,"lat":22.618281,"lon":120.332135,"acc":15.0,"spd":1.2},{"t":1794376463000,"lat":22.617974,"lon":120.332273,"acc":15.0,"spd":1.2},{"t":1794376468000,"lat":22.61814,"lon":120.332396,"acc":15.0,"spd":1.6},{"t":1794376473000,"lat":22.618065,"lon":120.332164,"acc":15.0,"spd":1.2},{"t":1794376478000,"lat":22.618219,"lon":120.332157,"acc":15.0,"spd":1.5},{"t":1794376483000,"lat":22.618375,"lon":120.332093,"acc":15.0,"spd":1.5},{"t":1794376488000,"lat":22.618304,"lon":120.332301,"acc":15.0,"spd":1.6},{"t":1794376493000,"lat":22.618459,"lon":120.332077,"acc":15.0,"spd":1.0},{"t":1794376498000,"lat":22.618559,"lon":120.332388,"acc":15.0,"spd":1.3},{"t":1794376503000,"lat":22.618617,"lon":120.332253,"acc":15.0,"spd":1.6},{"t":1794376508000,"lat":22.618641,"lon":120.332142,"acc":15.0,"spd":1.6},{"t":1794376513000,"lat":22.618677,"lon":120.332026,"acc":15.0,"spd":1.2},{"t":1794376518000,"lat":22.618785,"lon":120.332094,"acc":15.0,"spd":1.5},{"t":1794376523000,"lat":22.61889,"lon":120.332215,"acc":15.0,"spd":1.3},{"t":1794376528000,"lat":22.618829,"lon":120.332282,"acc":15.0,"spd":1.3},{"t":1794376533000,"lat":22.618927,"lon":120.332078,"acc":15.0,"spd":1.2},{"t":1794376538000,"lat":22.618905,"lon":120.332113,"acc":15.0,"spd":1.3},{"t":1794376543000,"lat":22.618977,"lon":120.331993,"acc":15.0,"spd":1.4},{"t":1794376548000,"lat":22.619192,"lon":120.332133,"acc":15.0,"spd":1.5},{"t":1794376553000,"lat":22.619168,"lon":120.331882,"acc":15.0,"spd":1.0},{"t":1794376558000,"lat":22.619137,"lon":120.331956,"acc":15.0,"spd":1.5},{"t":1794376563000,"lat":22.619332,"lon":120.332191,"acc":15.0,"spd":1.7},{"t":1794376568000,"lat":22.619372,"lon":120.332086,"acc":15.0,"spd":0.9},{"t":1794376573000,"lat":22.619364,"lon":120.331836,"acc":15.0,"spd":1.4},{"t":1794376578000,"lat":22.61967,"lon":120.331962,"acc":15.0,"spd":1.3},{"t":1794376583000,"lat":22.619471,"lon":120.33185,"acc":15.0,"spd":0.9},{"t":1794376588000,"lat":22.619585,"lon":120.332135,"acc":15.0,"spd":1.5},{"t":1794376593000,"lat":22.619609,"lon":120.332099,"acc":15.0,"spd":1.2},{"t":1794376598000,"lat":22.619941,"lon":120.331994,"acc":15.0,"spd":1.7},{"t":1794376603000,"lat":22.619744,"lon":120.331856,"acc":15.0,"spd":1.6},{"t":1794376608000,"lat":22.619995,"lon":120.331811,"acc":15.0,"spd":1.4},{"t":1794376613000,"lat":22.619861,"lon":120.331822,"acc":15.0,"spd":1.4},{"t":1794376618000,"lat":22.619905,"lon":120.331833,"acc":15.0,"spd":1.2},{"t":1794376623000,"lat":22.619881,"lon":120.331934,"acc":15.0,"spd":1.2},{"t":1794376628000,"lat":22.620196,"lon":120.331644,"acc":15.0,"spd":1.4},{"t":1794376633000,"lat":22.620224,"lon":120.331857,"acc":15.0,"spd":1.6},{"t":1794376638000,"lat":22.62019,"lon":120.33163,"acc":15.0,"spd":1.3},{"t":1794376643000,"lat":22.620266,"lon":120.331731,"acc":15.0,"spd":1.8},{"t":1794376648000,"lat":22.620581,"lon":120.331875,"acc":15.0,"spd":1.4},{"t":1794376653000,"lat":22.620509,"lon":120.331814,"acc":15.0,"spd":1.3},{"t":1794376658000,"lat":22.62049,"lon":120.331935,"acc":15.0,"spd":1.2},{"t":1794376663000,"lat":22.620534,"lon":120.33197,"acc":15.0,"spd":1.2},{"t":1794376668000,"lat":22.620761,"lon":120.331828,"acc":15.0,"spd":1.7},{"t":1794376673000,"lat":22.620854,"lon":120.331841,"acc":15.0,"spd":1.4},{"t":1794376678000,"lat":22.620628,"lon":120.331879,"acc":15.0,"spd":1.5},{"t":1794376683000,"lat":22.620952,"lon":120.331919,"acc":15.0,"spd":1.4},{"t":1794376688000,"lat":22.621029,"lon":120.331734,"acc":15.0,"spd":1.4},{"t":1794376693000,"lat":22.620961,"lon":120.331763,"acc":15.0,"spd":1.4},{"t":1794376698000,"lat":22.621112,"lon":120.331787,"acc":15.0,"spd":1.6},{"t":1794376703000,"lat":22.621055,"lon":120.331811,"acc":15.0,"spd":1.6},{"t":1794376708000,"lat":22.620862,"lon":120.331579,"acc":15.0,"spd":1.2},{"t":1794376713000,"lat":22.621277,"lon":120.331606,"acc":15.0,"spd":1.1},{"t":1794376718000,"lat":22.62109,"lon":120.33185,"acc":15.0,"spd":1.2},{"t":1794376723000,"lat":22.621352,"lon":120.331785,"acc":15.0,"spd":1.4},{"t":1794376728000,"lat":22.621367,"lon":120.33183,"acc":15.0,"spd":1.3},{"t":1794376733000,"lat":22.62135,"lon":120.331708,"acc":15.0,"spd":1.5},{"t":1794376738000,"lat":22.621439,"lon":120.331599,"acc":15.0,"spd":1.3},{"t":1794376743000,"lat":22.62144,"lon":120.331799,"acc":15.0,"spd":1.5},{"t":1794376748000,"lat":22.621523,"lon":120.331695,"acc":15.0,"spd":1.7},{"t":1794376753000,"lat":22.621671,"lon":120.331773,"acc":15.0,"spd":1.4},{"t":1794376758000,"lat":22.621433,"lon":120.331598,"acc":15.0,"spd":1.3},{"t":1794376763000,"lat":22.621592,"lon":120.331502,"acc":15.0,"spd":1.8},{"t":1794376768000,"lat":22.621573,"lon":120.331308,"acc":15.0,"spd":1.6},{"t":1794376773000,"lat":22.621692,"lon":120.331012,"acc":15.0,"spd":1.3},{"t":1794376778000,"lat":22.62165,"lon":120.331012,"acc":15.0,"spd":1.5},{"t":1794376783000,"lat":22.621556,"lon":120.331436,"acc":15.0,"spd":1.2},{"t":1794376788000,"lat":22.621689,"lon":120.330738,"acc":15.0,"spd":1.4},{"t":1794376793000,"lat":22.621416,"lon":120.330685,"acc":15.0,"spd":1.1},{"t":1794376798000,"lat":22.621593,"lon":120.330996,"acc":15.0,"spd":1.3},{"t":1794376803000,"lat":22.621762,"lon":120.330811,"acc":15.0,"spd":1.2},{"t":1794376808000,"lat":22.621892,"lon":120.330967,"acc":15.0,"spd":1.3},{"t":1794376813000,"lat":22.621551,"lon":120.330504,"acc":15.0,"spd":1.4},{"t":1794376818000,"lat":22.621673,"lon":120.330499,"acc":15.0,"spd":1.4},{"t":1794376823000,"lat":22.621776,"lon":120.330584,"acc":15.0,"spd":1.6},{"t":1794376828000,"lat":22.62177,"lon":120.330465,"acc":15.0,"spd":1.5},{"t":1794376833000,"lat":22.621631,"lon":120.330567,"acc":15.0,"spd":1.6},{"t":1794376838000,"lat":22.621666,"lon":120.330275,"acc":15.0,"spd":1.4},{"t":1794376843000,"lat":22.621701,"lon":120.330459,"acc":15.0,"spd":1.2},{"t":1794376848000,"lat":22.621857,"lon":120.330308,"acc":15.0,"spd":1.0},{"t":1794376853000,"lat":22.621786,"lon":120.330178,"acc":15.0,"spd":1.3},{"t":1794376858000,"lat":22.621999,"lon":120.33014,"acc":15.0,"spd":1.0},{"t":1794376863000,"lat":22.621839,"lon":120.329829,"acc":15.0,"spd":1.5},{"t":1794376868000,"lat":22.621799,"lon":120.33011,"acc":15.0,"spd":1.4},{"t":1794376873000,"lat":22.621896,"lon":120.329937,"acc":15.0,"spd":1.0},{"t":1794376878000,"lat":22.621861,"lon":120.329901,"acc":15.0,"spd":1.7},{"t":1794376883000,"lat":22.622008,"lon":120.329863,"acc":15.0,"spd":1.0},{"t":1794376888000,"lat":22.622009,"lon":120.329755,"acc":15.0,"spd":1.2},{"t":1794376893000,"lat":22.622205,"lon":120.329634,"acc":15.0,"spd":1.2},{"t":1794376898000,"lat":22.622006,"lon":120.329427,"acc":15.0,"spd":1.0},{"t":1794376903000,"lat":22.622061,"lon":120.329519,"acc":15.0,"spd":1.6},{"t":1794376908000,"lat":22.622182,"lon":120.329402,"acc":15.0,"spd":1.6},{"t":1794376913000,"lat":22.622214,"lon":120.329375,"acc":15.0,"spd":1.1},{"t":1794376918000,"lat":22.621946,"lon":120.329314,"acc":15.0,"spd":1.6},{"t":1794376923000,"lat":22.622088,"lon":120.329265,"acc":15.0,"spd":1.2},{"t":1794376928000,"lat":22.622308,"lon":120.329112,"acc":15.0,"spd":1.5},{"t":1794376933000,"lat":22.622194,"lon":120.329114,"acc":15.0,"spd":1.4},{"t":1794376938000,"lat":22.622092,"lon":120.329163,"acc":15.0,"spd":1.4},{"t":1794376943000,"lat":22.62189,"lon":120.329141,"acc":15.0,"spd":1.3},{"t":1794376948000,"lat":22.622042,"lon":120.329097,"acc":15.0,"spd":1.6},{"t":1794376953000,"lat":22.622025,"lon":120.329013,"acc":15.0,"spd":1.5},{"t":1794376958000,"lat":22.621949,"lon":120.328775,"acc":15.0,"spd":1.6},{"t":1794376963000,"lat":22.622014,"lon":120.32879,"acc":15.0,"spd":1.6},{"t":1794376968000,"lat":22.622076,"lon":120.328783,"acc":15.0,"spd":1.2},{"t":1794376973000,"lat":22.621998,"lon":120.328536,"acc":15.0,"spd":1.6},{"t":1794376978000,"lat":22.621805,"lon":120.328521,"acc":15.0,"spd":1.3},{"t":1794376983000,"lat":22.621944,"lon":120.328646,"acc":15.0,"spd":1.3},{"t":1794376988000,"lat":22.62191,"lon":120.328368,"acc":15.0,"spd":1.6},{"t":1794376993000,"lat":22.621777,"lon":120.328432,"acc":15.0,"spd":1.5},{"t":1794376998000,"lat":22.621851,"lon":120.328398,"acc":15.0,"spd":1.6},{"t":1794377003000,"lat":22.621877,"lon":120.328271,"acc":15.0,"spd":1.0},{"t":1794377008000,"lat":22.621843,"lon":120.328245,"acc":15.0,"spd":1.6},{"t":1794377013000,"lat":22.621799,"lon":120.327947,"acc":15.0,"spd":1.3},{"t":1794377018000,"lat":22.621598,"lon":120.327929,"acc":15.0,"spd":1.6},{"t":1794377023000,"lat":22.621742,"lon":120.32815,"acc":15.0,"spd":1.5},{"t":1794377028000,"lat":22.621536,"lon":120.327754,"acc":15.0,"spd":1.3},{"t":1794377033000,"lat":22.621611,"lon":120.327814,"acc":15.0,"spd":1.5},{"t":1794377038000,"lat":22.621682,"lon":120.327876,"acc":15.0,"spd":1.6},{"t":1794377043000,"lat":22.621716,"lon":120.327651,"acc":15.0,"spd":1.6},{"t":1794377048000,"lat":22.621787,"lon":120.327849,"acc":15.0,"spd":0.9},{"t":1794377053000,"lat":22.621575,"lon":120.32762,"acc":15.0,"spd":1.4},{"t":1794377058000,"lat":22.621435,"lon":120.32754,"acc":15.0,"spd":1.3},{"t":1794377063000,"lat":22.621627,"lon":120.327574,"acc":15.0,"spd":1.0},{"t":1794377068000,"lat":22.621487,"lon":120.3273,"acc":15.0,"spd":1.4},{"t":1794377073000,"lat":22.62134,"lon":120.327175,"acc":15.0,"spd":1.6},{"t":1794377078000,"lat":22.621394,"lon":120.327418,"acc":15.0,"spd":1.7},{"t":1794377083000,"lat":22.621331,"lon":120.327218,"acc":15.0,"spd":1.2},{"t":1794377088000,"lat":22.62139,"lon":120.327195,"acc":15.0,"spd":1.4},{"t":1794377093000,"lat":22.621535,"lon":120.327205,"acc":15.0,"spd":1.4},{"t":1794377098000,"lat":22.621351,"lon":120.327298,"acc":15.0,"spd":1.4},{"t":1794377103000,"lat":22.621292,"lon":120.326982,"acc":15.0,"spd":1.1},{"t":1794377108000,"lat":22.621337,"lon":120.32665,"acc":15.0,"spd":1.5},{"t":1794377113000,"lat":22.621234,"lon":120.326772,"acc":15.0,"spd":1.7},{"t":1794377118000,"lat":22.621324,"lon":120.326658,"acc":15.0,"spd":1.3},{"t":1794377123000,"lat":22.621323,"lon":120.32665,"acc":15.0,"spd":1.1},{"t":1794377128000,"lat":22.621556,"lon":120.326646,"acc":15.0,"spd":1.5},{"t":1794377133000,"lat":22.621288,"lon":120.326628,"acc":15.0,"spd":1.2},{"t":1794377138000,"lat":22.62109,"lon":120.326561,"acc":15.0,"spd":1.8},{"t":1794377143000,"lat":22.621125,"lon":120.326342,"acc":15.0,"spd":1.1},{"t":1794377148000,"lat":22.621346,"lon":120.326187,"acc":15.0,"spd":1.3},{"t":1794377153000,"lat":22.621292,"lon":120.326027,"acc":15.0,"spd":1.4},{"t":1794377158000,"lat":22.621161,"lon":120.326169,"acc":15.0,"spd":1.3},{"t":1794377163000,"lat":22.620966,"lon":120.3261,"acc":15.0,"spd":1.2},{"t":1794377168000,"lat":22.62092,"lon":120.326192,"acc":15.0,"spd":1.2},{"t":1794377173000,"lat":22.62096,"lon":120.326102,"acc":15.0,"spd":1.5},{"t":1794377178000,"lat":22.621118,"lon":120.325969,"acc":15.0,"spd":1.3},{"t":1794377183000,"lat":22.621097,"lon":120.325986,"acc":15.0,"spd":1.2},{"t":1794377188000,"lat":22.620967,"lon":120.325834,"acc":15.0,"spd":1.3},{"t":1794377193000,"lat":22.620928,"lon":120.325764,"acc":15.0,"spd":1.7},{"t":1794377198000,"lat":22.621029,"lon":120.325758,"acc":15.0,"spd":1.2},{"t":1794377203000,"lat":22.620931,"lon":120.325643,"acc":15.0,"spd":1.5},{"t":1794377208000,"lat":22.620898,"lon":120.325711,"acc":15.0,"spd":1.3},{"t":1794377213000,"lat":22.620882,"lon":120.325499,"acc":15.0,"spd":1.8},{"t":1794377218000,"lat":22.620907,"lon":120.325605,"acc":15.0,"spd":1.3},{"t":1794377223000,"lat":22.620801,"lon":120.325437,"acc":15.0,"spd":1.3},{"t":1794377228000,"lat":22.620709,"lon":120.325607,"acc":15.0,"spd":1.3},{"t":1794377233000,"lat":22.620688,"lon":120.325388,"acc":15.0,"spd":1.5},{"t":1794377238000,"lat":22.620714,"lon":120.325226,"acc":15.0,"spd":1.4},{"t":1794377243000,"lat":22.620793,"lon":120.325334,"acc":15.0,"spd":1.4},{"t":1794377248000,"lat":22.620859,"lon":120.325101,"acc":15.0,"spd":1.9},{"t":1794377253000,"lat":22.620678,"lon":120.325169,"acc":15.0,"spd":1.3},{"t":1794377258000,"lat":22.620654,"lon":120.324952,"acc":15.0,"spd":1.5},{"t":1794377263000,"lat":22.620672,"lon":120.324923,"acc":15.0,"spd":1.1},{"t":1794377268000,"lat":22.620705,"lon":120.325029,"acc":15.0,"spd":1.6},{"t":1794377273000,"lat":22.620471,"lon":120.324561,"acc":15.0,"spd":1.5},{"t":1794377278000,"lat":22.620809,"lon":120.324603,"acc":15.0,"spd":1.4},{"t":1794377283000,"lat":22.620494,"lon":120.324811,"acc":15.0,"spd":1.2},{"t":1794377288000,"lat":22.620711,"lon":120.324371,"acc":15.0,"spd":1.4},{"t":1794377293000,"lat":22.620566,"lon":120.324523,"acc":15.0,"spd":1.3},{"t":1794377298000,"lat":22.620341,"lon":120.324477,"acc":15.0,"spd":1.3},{"t":1794377303000,"lat":22.620405,"lon":120.324148,"acc":15.0,"spd":1.6},{"t":1794377308000,"lat":22.62024,"lon":120.324201,"acc":15.0,"spd":1.1},{"t":1794377313000,"lat":22.62046,"lon":120.324357,"acc":15.0,"spd":1.2},{"t":1794377318000,"lat":22.620428,"lon":120.324381,"acc":15.0,"spd":1.6},{"t":1794377323000,"lat":22.620354,"lon":120.324039,"acc":15.0,"spd":1.3},{"t":1794377328000,"lat":22.62044,"lon":120.324012,"acc":15.0,"spd":1.3},{"t":1794377333000,"lat":22.620272,"lon":120.32394,"acc":15.0,"spd":1.5},{"t":1794377338000,"lat":22.620346,"lon":120.324133,"acc":15.0,"spd":1.3},{"t":1794377343000,"lat":22.620198,"lon":120.324045,"acc":15.0,"spd":1.4},{"t":1794377348000,"lat":22.620254,"lon":120.323619,"acc":15.0,"spd":1.2},{"t":1794377353000,"lat":22.62037,"lon":120.323654,"acc":15.0,"spd":1.7},{"t":1794377358000,"lat":22.620089,"lon":120.323623,"acc":15.0,"spd":1.6},{"t":1794377363000,"lat":22.62015,"lon":120.323658,"acc":15.0,"spd":1.2},{"t":1794377368000,"lat":22.620264,"lon":120.323597,"acc":15.0,"spd":1.4},{"t":1794377373000,"lat":22.620052,"lon":120.323512,"acc":15.0,"spd":1.4},{"t":1794377378000,"lat":22.620318,"lon":120.323528,"acc":15.0,"spd":1.2},{"t":1794377383000,"lat":22.620074,"lon":120.323384,"acc":15.0,"spd":1.5},{"t":1794377388000,"lat":22.620089,"lon":120.323235,"acc":15.0,"spd":1.2},{"t":1794377393000,"lat":22.620176,"lon":120.323168,"acc":15.0,"spd":1.3},{"t":1794377398000,"lat":22.620247,"lon":120.323233,"acc":15.0,"spd":1.2},{"t":1794377403000,"lat":22.620046,"lon":120.32316,"acc":15.0,"spd":1.6},{"t":1794377408000,"lat":22.620007,"lon":120.323223,"acc":15.0,"spd":1.7}]} \ No newline at end of file diff --git a/android/app/src/test/resources/ride_replay_TPE157463_0.json b/android/app/src/test/resources/ride_replay_TPE157463_0.json new file mode 100644 index 0000000..6c676f5 --- /dev/null +++ b/android/app/src/test/resources/ride_replay_TPE157463_0.json @@ -0,0 +1 @@ +{"route":"TPE157463","pathId":0,"plate":"EAL-1162","boardedAtMs":1794376456264,"boardingStopIdRaw":"15232","destinationStopIdRaw":"15242","stops":[{"stopIdRaw":"194687","sequence":1,"lat":25.022559,"lon":121.452109},{"stopIdRaw":"194688","sequence":2,"lat":25.026206,"lon":121.452324},{"stopIdRaw":"221716","sequence":3,"lat":25.0258419392865,"lon":121.453808034732},{"stopIdRaw":"15198","sequence":4,"lat":25.023423,"lon":121.454215},{"stopIdRaw":"15199","sequence":5,"lat":25.020666,"lon":121.454666},{"stopIdRaw":"15200","sequence":6,"lat":25.01938238,"lon":121.45531395},{"stopIdRaw":"15201","sequence":7,"lat":25.016976,"lon":121.456421},{"stopIdRaw":"15202","sequence":8,"lat":25.014996,"lon":121.45642},{"stopIdRaw":"15203","sequence":9,"lat":25.01274499,"lon":121.4561617},{"stopIdRaw":"15204","sequence":10,"lat":25.010986,"lon":121.456883},{"stopIdRaw":"15205","sequence":11,"lat":25.009036,"lon":121.458454},{"stopIdRaw":"15206","sequence":12,"lat":25.013298,"lon":121.460871},{"stopIdRaw":"15207","sequence":13,"lat":25.01277,"lon":121.462542},{"stopIdRaw":"153799","sequence":14,"lat":25.0118568,"lon":121.4643742},{"stopIdRaw":"153800","sequence":15,"lat":25.009624,"lon":121.465209},{"stopIdRaw":"15208","sequence":16,"lat":25.00839,"lon":121.46584},{"stopIdRaw":"15209","sequence":17,"lat":25.007531,"lon":121.46822},{"stopIdRaw":"15210","sequence":18,"lat":25.006683,"lon":121.470497},{"stopIdRaw":"15211","sequence":19,"lat":25.00622954,"lon":121.4730341},{"stopIdRaw":"15212","sequence":20,"lat":25.005765,"lon":121.47597},{"stopIdRaw":"15213","sequence":21,"lat":25.005751,"lon":121.478674},{"stopIdRaw":"199119","sequence":22,"lat":25.004871,"lon":121.480278},{"stopIdRaw":"15214","sequence":23,"lat":25.00395,"lon":121.480636},{"stopIdRaw":"15215","sequence":24,"lat":25.000604,"lon":121.481193},{"stopIdRaw":"15216","sequence":25,"lat":24.998038,"lon":121.481182},{"stopIdRaw":"15217","sequence":26,"lat":24.99579873,"lon":121.4809504},{"stopIdRaw":"15218","sequence":27,"lat":24.995553,"lon":121.482957},{"stopIdRaw":"15219","sequence":28,"lat":24.996128,"lon":121.484429},{"stopIdRaw":"15220","sequence":29,"lat":24.99626066,"lon":121.4903845},{"stopIdRaw":"15222","sequence":30,"lat":24.9965568353221,"lon":121.492320399314},{"stopIdRaw":"15224","sequence":31,"lat":24.99819818,"lon":121.4951329},{"stopIdRaw":"15226","sequence":32,"lat":24.9996352583255,"lon":121.496428401096},{"stopIdRaw":"201702","sequence":33,"lat":25.001888,"lon":121.496889},{"stopIdRaw":"15228","sequence":34,"lat":25.003062,"lon":121.496762},{"stopIdRaw":"15230","sequence":35,"lat":25.01987,"lon":121.497706},{"stopIdRaw":"15232","sequence":36,"lat":25.0232906952259,"lon":121.499894100772},{"stopIdRaw":"15234","sequence":37,"lat":25.025045,"lon":121.500485},{"stopIdRaw":"15236","sequence":38,"lat":25.028737,"lon":121.500599},{"stopIdRaw":"15238","sequence":39,"lat":25.0308055,"lon":121.500635},{"stopIdRaw":"15240","sequence":40,"lat":25.03113,"lon":121.50255},{"stopIdRaw":"15242","sequence":41,"lat":25.03268103989999,"lon":121.50499126498305},{"stopIdRaw":"15244","sequence":42,"lat":25.0357,"lon":121.506607},{"stopIdRaw":"15246","sequence":43,"lat":25.041730058378,"lon":121.508285900629},{"stopIdRaw":"15248","sequence":44,"lat":25.0456964941835,"lon":121.509380726049},{"stopIdRaw":"15250","sequence":45,"lat":25.0465635496954,"lon":121.515689438859},{"stopIdRaw":"15252","sequence":46,"lat":25.04483077,"lon":121.5215079},{"stopIdRaw":"15254","sequence":47,"lat":25.0437303889139,"lon":121.522278540137},{"stopIdRaw":"15256","sequence":48,"lat":25.046759,"lon":121.523978},{"stopIdRaw":"15258","sequence":49,"lat":25.04809523,"lon":121.524405},{"stopIdRaw":"15260","sequence":50,"lat":25.05059,"lon":121.52495},{"stopIdRaw":"15262","sequence":51,"lat":25.0520078098529,"lon":121.529855025946},{"stopIdRaw":"15264","sequence":52,"lat":25.0519912439505,"lon":121.532641149751},{"stopIdRaw":"15266","sequence":53,"lat":25.0519389515979,"lon":121.53627250499},{"stopIdRaw":"15268","sequence":54,"lat":25.05189813653983,"lon":121.54002052720857},{"stopIdRaw":"15270","sequence":55,"lat":25.0518040049938,"lon":121.543561439976},{"stopIdRaw":"15272","sequence":56,"lat":25.0517035084006,"lon":121.548353475095},{"stopIdRaw":"15274","sequence":57,"lat":25.051594154729063,"lon":121.55469770362868},{"stopIdRaw":"15276","sequence":58,"lat":25.05148353485096,"lon":121.5602663380149},{"stopIdRaw":"15278","sequence":59,"lat":25.0512559293088,"lon":121.566535683299},{"stopIdRaw":"15280","sequence":60,"lat":25.05111499,"lon":121.5688141},{"stopIdRaw":"15282","sequence":61,"lat":25.05220024,"lon":121.5704299},{"stopIdRaw":"15284","sequence":62,"lat":25.055027,"lon":121.569875},{"stopIdRaw":"15286","sequence":63,"lat":25.0577179708185,"lon":121.569393769344},{"stopIdRaw":"15290","sequence":64,"lat":25.06018904,"lon":121.5676949},{"stopIdRaw":"15292","sequence":65,"lat":25.062445,"lon":121.566269},{"stopIdRaw":"15288","sequence":66,"lat":25.06530387,"lon":121.5657277}],"busReports":[{"id":"EAA-150","dir":0,"lat":25.051362,"lon":121.569912,"t":1794376137000,"rx":1794376159195},{"id":"EAL-0072","dir":0,"lat":24.996637,"lon":121.492255,"t":1794376132000,"rx":1794376159195},{"id":"EAL-0080","dir":0,"lat":25.02065,"lon":121.454847,"t":1794376143000,"rx":1794376159195},{"id":"EAL-0081","dir":0,"lat":25.006282,"lon":121.473548,"t":1794376135000,"rx":1794376159195},{"id":"EAL-0097","dir":0,"lat":25.01152,"lon":121.46478,"t":1794376135000,"rx":1794376159195},{"id":"EAL-0236","dir":0,"lat":25.022667,"lon":121.49937,"t":1794376141000,"rx":1794376159195},{"id":"EAL-1157","dir":0,"lat":25.008973,"lon":121.45889,"t":1794376147000,"rx":1794376159195},{"id":"EAL-1161","dir":0,"lat":25.046613,"lon":121.51605,"t":1794376146000,"rx":1794376159195},{"id":"EAL-1162","dir":0,"lat":25.00514,"lon":121.496128,"t":1794376144000,"rx":1794376159195},{"id":"EAL-1168","dir":0,"lat":25.031327,"lon":121.503733,"t":1794376140000,"rx":1794376159195},{"id":"EAL-1171","dir":0,"lat":25.060372,"lon":121.567445,"t":1794376136000,"rx":1794376159195},{"id":"EAL-1173","dir":0,"lat":25.051192,"lon":121.56933,"t":1794376136000,"rx":1794376159195},{"id":"EAL-1299","dir":0,"lat":25.054982,"lon":121.569928,"t":1794376137000,"rx":1794376159195},{"id":"EAL-2266","dir":0,"lat":25.04567,"lon":121.50954,"t":1794376136000,"rx":1794376159195},{"id":"EAL-2267","dir":0,"lat":25.020692,"lon":121.498098,"t":1794376148000,"rx":1794376159195},{"id":"EAL-0072","dir":0,"lat":24.9971,"lon":121.493345,"t":1794376152000,"rx":1794376168901},{"id":"EAL-0081","dir":0,"lat":25.006292,"lon":121.473568,"t":1794376155000,"rx":1794376168901},{"id":"EAL-0097","dir":0,"lat":25.011502,"lon":121.464805,"t":1794376155000,"rx":1794376168901},{"id":"EAL-2267","dir":0,"lat":25.021033,"lon":121.498368,"t":1794376155000,"rx":1794376168901},{"id":"EAA-150","dir":0,"lat":25.051368,"lon":121.569913,"t":1794376157000,"rx":1794376180724},{"id":"EAL-0080","dir":0,"lat":25.020175,"lon":121.454952,"t":1794376165000,"rx":1794376180724},{"id":"EAL-0236","dir":0,"lat":25.023803,"lon":121.500163,"t":1794376157000,"rx":1794376180724},{"id":"EAL-1157","dir":0,"lat":25.008973,"lon":121.458888,"t":1794376167000,"rx":1794376180724},{"id":"EAL-1161","dir":0,"lat":25.046475,"lon":121.516588,"t":1794376166000,"rx":1794376180724},{"id":"EAL-1162","dir":0,"lat":25.007122,"lon":121.495417,"t":1794376164000,"rx":1794376180724},{"id":"EAL-1168","dir":0,"lat":25.03133,"lon":121.503733,"t":1794376160000,"rx":1794376180724},{"id":"EAL-1171","dir":0,"lat":25.060748,"lon":121.56714,"t":1794376167000,"rx":1794376180724},{"id":"EAL-1173","dir":0,"lat":25.051187,"lon":121.569333,"t":1794376156000,"rx":1794376180724},{"id":"EAL-1299","dir":0,"lat":25.056717,"lon":121.569568,"t":1794376167000,"rx":1794376180724},{"id":"EAL-2266","dir":0,"lat":25.046543,"lon":121.50969,"t":1794376167000,"rx":1794376180724},{"id":"EAL-0072","dir":0,"lat":24.997302,"lon":121.493515,"t":1794376172000,"rx":1794376193174},{"id":"EAL-0080","dir":0,"lat":25.019608,"lon":121.455247,"t":1794376171000,"rx":1794376193174},{"id":"EAL-0081","dir":0,"lat":25.00627,"lon":121.47371,"t":1794376175000,"rx":1794376193174},{"id":"EAL-0097","dir":0,"lat":25.01145,"lon":121.464812,"t":1794376175000,"rx":1794376193174},{"id":"EAL-2267","dir":0,"lat":25.022483,"lon":121.49924,"t":1794376175000,"rx":1794376193174},{"id":"EAA-150","dir":0,"lat":25.051317,"lon":121.569867,"t":1794376197000,"rx":1794376206585},{"id":"EAL-0072","dir":0,"lat":24.997305,"lon":121.493512,"t":1794376192000,"rx":1794376206585},{"id":"EAL-0080","dir":0,"lat":25.019308,"lon":121.455277,"t":1794376183000,"rx":1794376206585},{"id":"EAL-0081","dir":0,"lat":25.006045,"lon":121.475322,"t":1794376196000,"rx":1794376206585},{"id":"EAL-0097","dir":0,"lat":25.010132,"lon":121.465532,"t":1794376195000,"rx":1794376206585},{"id":"EAL-0236","dir":0,"lat":25.025217,"lon":121.500535,"t":1794376197000,"rx":1794376206585},{"id":"EAL-1157","dir":0,"lat":25.008882,"lon":121.459645,"t":1794376194000,"rx":1794376206585},{"id":"EAL-1161","dir":0,"lat":25.046363,"lon":121.516978,"t":1794376186000,"rx":1794376206585},{"id":"EAL-1162","dir":0,"lat":25.00928,"lon":121.495455,"t":1794376184000,"rx":1794376206585},{"id":"EAL-1168","dir":0,"lat":25.031315,"lon":121.503722,"t":1794376180000,"rx":1794376206585},{"id":"EAL-1171","dir":0,"lat":25.062418,"lon":121.566275,"t":1794376196000,"rx":1794376206585},{"id":"EAL-1173","dir":0,"lat":25.051187,"lon":121.569338,"t":1794376196000,"rx":1794376206585},{"id":"EAL-1299","dir":0,"lat":25.058962,"lon":121.569083,"t":1794376197000,"rx":1794376206585},{"id":"EAL-2266","dir":0,"lat":25.048162,"lon":121.510463,"t":1794376196000,"rx":1794376206585},{"id":"EAL-2267","dir":0,"lat":25.023798,"lon":121.500078,"t":1794376195000,"rx":1794376206585},{"id":"EAA-150","dir":0,"lat":25.051927,"lon":121.570378,"t":1794376224000,"rx":1794376231055},{"id":"EAL-0072","dir":0,"lat":24.997362,"lon":121.493583,"t":1794376212000,"rx":1794376231055},{"id":"EAL-0080","dir":0,"lat":25.01755,"lon":121.456193,"t":1794376223000,"rx":1794376231055},{"id":"EAL-0081","dir":0,"lat":25.006402,"lon":121.47662,"t":1794376215000,"rx":1794376231055},{"id":"EAL-0097","dir":0,"lat":25.009588,"lon":121.465322,"t":1794376215000,"rx":1794376231055},{"id":"EAL-0236","dir":0,"lat":25.026015,"lon":121.500582,"t":1794376217000,"rx":1794376231055},{"id":"EAL-1157","dir":0,"lat":25.009062,"lon":121.459785,"t":1794376207000,"rx":1794376231055},{"id":"EAL-1161","dir":0,"lat":25.046302,"lon":121.517203,"t":1794376206000,"rx":1794376231055},{"id":"EAL-1162","dir":0,"lat":25.013623,"lon":121.495552,"t":1794376224000,"rx":1794376231055},{"id":"EAL-1168","dir":0,"lat":25.031295,"lon":121.503828,"t":1794376220000,"rx":1794376231055},{"id":"EAL-1171","dir":0,"lat":25.06249,"lon":121.566233,"t":1794376216000,"rx":1794376231055},{"id":"EAL-1173","dir":0,"lat":25.051167,"lon":121.570018,"t":1794376217000,"rx":1794376231055},{"id":"EAL-1299","dir":0,"lat":25.059078,"lon":121.568983,"t":1794376217000,"rx":1794376231055},{"id":"EAL-2266","dir":0,"lat":25.047532,"lon":121.512113,"t":1794376216000,"rx":1794376231055},{"id":"EAL-2267","dir":0,"lat":25.024523,"lon":121.500352,"t":1794376215000,"rx":1794376231055},{"id":"EAA-150","dir":0,"lat":25.053057,"lon":121.570193,"t":1794376237000,"rx":1794376254335},{"id":"EAL-0072","dir":0,"lat":24.997832,"lon":121.4943,"t":1794376232000,"rx":1794376254335},{"id":"EAL-0081","dir":0,"lat":25.005695,"lon":121.477088,"t":1794376239000,"rx":1794376254335},{"id":"EAL-0097","dir":0,"lat":25.009383,"lon":121.465198,"t":1794376235000,"rx":1794376254335},{"id":"EAL-0236","dir":0,"lat":25.026083,"lon":121.500545,"t":1794376237000,"rx":1794376254335},{"id":"EAL-1157","dir":0,"lat":25.010802,"lon":121.461342,"t":1794376227000,"rx":1794376254335},{"id":"EAL-1161","dir":0,"lat":25.046338,"lon":121.517218,"t":1794376226000,"rx":1794376254335},{"id":"EAL-1168","dir":0,"lat":25.031347,"lon":121.503973,"t":1794376231000,"rx":1794376254335},{"id":"EAL-1171","dir":0,"lat":25.062852,"lon":121.566078,"t":1794376236000,"rx":1794376254335},{"id":"EAL-1173","dir":0,"lat":25.051887,"lon":121.570415,"t":1794376236000,"rx":1794376254335},{"id":"EAL-1299","dir":0,"lat":25.05942,"lon":121.56847,"t":1794376237000,"rx":1794376254335},{"id":"EAL-2266","dir":0,"lat":25.04701,"lon":121.514042,"t":1794376236000,"rx":1794376254335},{"id":"EAL-2267","dir":0,"lat":25.025097,"lon":121.500505,"t":1794376235000,"rx":1794376254335},{"id":"EAA-150","dir":0,"lat":25.054225,"lon":121.570087,"t":1794376257000,"rx":1794376276680},{"id":"EAL-0072","dir":0,"lat":24.998227,"lon":121.494957,"t":1794376252000,"rx":1794376276680},{"id":"EAL-0080","dir":0,"lat":25.016438,"lon":121.456527,"t":1794376263000,"rx":1794376276680},{"id":"EAL-0081","dir":0,"lat":25.00584,"lon":121.478535,"t":1794376255000,"rx":1794376276680},{"id":"EAL-0097","dir":0,"lat":25.009195,"lon":121.465118,"t":1794376255000,"rx":1794376276680},{"id":"EAL-0236","dir":0,"lat":25.026055,"lon":121.500548,"t":1794376257000,"rx":1794376276680},{"id":"EAL-1157","dir":0,"lat":25.011257,"lon":121.46172,"t":1794376267000,"rx":1794376276680},{"id":"EAL-1161","dir":0,"lat":25.046262,"lon":121.517378,"t":1794376266000,"rx":1794376276680},{"id":"EAL-1162","dir":0,"lat":25.017432,"lon":121.496095,"t":1794376264000,"rx":1794376276680},{"id":"EAL-1168","dir":0,"lat":25.032085,"lon":121.504515,"t":1794376260000,"rx":1794376276680},{"id":"EAL-1171","dir":0,"lat":25.062857,"lon":121.566085,"t":1794376256000,"rx":1794376276680},{"id":"EAL-1173","dir":0,"lat":25.05395,"lon":121.57008,"t":1794376256000,"rx":1794376276680},{"id":"EAL-1299","dir":0,"lat":25.059725,"lon":121.568095,"t":1794376257000,"rx":1794376276680},{"id":"EAL-2266","dir":0,"lat":25.046703,"lon":121.515448,"t":1794376256000,"rx":1794376276680},{"id":"EAL-2267","dir":0,"lat":25.025092,"lon":121.500507,"t":1794376255000,"rx":1794376276680},{"id":"EAA-150","dir":0,"lat":25.054278,"lon":121.570082,"t":1794376277000,"rx":1794376300571},{"id":"EAL-0072","dir":0,"lat":24.998387,"lon":121.495215,"t":1794376272000,"rx":1794376300571},{"id":"EAL-0080","dir":0,"lat":25.015747,"lon":121.456413,"t":1794376285000,"rx":1794376300571},{"id":"EAL-0081","dir":0,"lat":25.005848,"lon":121.47865,"t":1794376275000,"rx":1794376300571},{"id":"EAL-0097","dir":0,"lat":25.009188,"lon":121.46512,"t":1794376275000,"rx":1794376300571},{"id":"EAL-0236","dir":0,"lat":25.026525,"lon":121.50053,"t":1794376277000,"rx":1794376300571},{"id":"EAL-1162","dir":0,"lat":25.01939,"lon":121.497292,"t":1794376284000,"rx":1794376300571},{"id":"EAL-1168","dir":0,"lat":25.032603,"lon":121.504913,"t":1794376280000,"rx":1794376300571},{"id":"EAL-1171","dir":0,"lat":25.062862,"lon":121.566085,"t":1794376276000,"rx":1794376300571},{"id":"EAL-1173","dir":0,"lat":25.054045,"lon":121.570148,"t":1794376276000,"rx":1794376300571},{"id":"EAL-1299","dir":0,"lat":25.060237,"lon":121.567668,"t":1794376277000,"rx":1794376300571},{"id":"EAL-2266","dir":0,"lat":25.046533,"lon":121.515635,"t":1794376276000,"rx":1794376300571},{"id":"EAL-2267","dir":0,"lat":25.025222,"lon":121.500507,"t":1794376275000,"rx":1794376300571},{"id":"EAA-150","dir":0,"lat":25.05574,"lon":121.569775,"t":1794376305000,"rx":1794376312722},{"id":"EAA-151","dir":0,"lat":25.023818,"lon":121.450015,"t":1794376305000,"rx":1794376312722},{"id":"EAL-0072","dir":0,"lat":24.999408,"lon":121.496043,"t":1794376299000,"rx":1794376312722},{"id":"EAL-0080","dir":0,"lat":25.0152,"lon":121.456417,"t":1794376303000,"rx":1794376312722},{"id":"EAL-0081","dir":0,"lat":25.006085,"lon":121.47951,"t":1794376295000,"rx":1794376312722},{"id":"EAL-0097","dir":0,"lat":25.009172,"lon":121.465122,"t":1794376295000,"rx":1794376312722},{"id":"EAL-0236","dir":0,"lat":25.027952,"lon":121.500578,"t":1794376297000,"rx":1794376312722},{"id":"EAL-1157","dir":0,"lat":25.011192,"lon":121.461643,"t":1794376287000,"rx":1794376312722},{"id":"EAL-1161","dir":0,"lat":25.045955,"lon":121.518333,"t":1794376286000,"rx":1794376312722},{"id":"EAL-1162","dir":0,"lat":25.019922,"lon":121.497662,"t":1794376304000,"rx":1794376312722},{"id":"EAL-1168","dir":0,"lat":25.033607,"lon":121.505372,"t":1794376300000,"rx":1794376312722},{"id":"EAL-1171","dir":0,"lat":25.062963,"lon":121.566062,"t":1794376296000,"rx":1794376312722},{"id":"EAL-1173","dir":0,"lat":25.054422,"lon":121.570057,"t":1794376296000,"rx":1794376312722},{"id":"EAL-1299","dir":0,"lat":25.06029,"lon":121.567612,"t":1794376297000,"rx":1794376312722},{"id":"EAL-2266","dir":0,"lat":25.046543,"lon":121.515867,"t":1794376297000,"rx":1794376312722},{"id":"EAL-2267","dir":0,"lat":25.026505,"lon":121.500542,"t":1794376305000,"rx":1794376312722},{"id":"EAA-150","dir":0,"lat":25.056743,"lon":121.569603,"t":1794376317000,"rx":1794376336865},{"id":"EAA-151","dir":0,"lat":25.023868,"lon":121.449793,"t":1794376312000,"rx":1794376336865},{"id":"EAL-0072","dir":0,"lat":24.999837,"lon":121.49632,"t":1794376312000,"rx":1794376336865},{"id":"EAL-0080","dir":0,"lat":25.014305,"lon":121.456333,"t":1794376327000,"rx":1794376336865},{"id":"EAL-0081","dir":0,"lat":25.006078,"lon":121.479553,"t":1794376315000,"rx":1794376336865},{"id":"EAL-0097","dir":0,"lat":25.008882,"lon":121.465007,"t":1794376315000,"rx":1794376336865},{"id":"EAL-0236","dir":0,"lat":25.029173,"lon":121.50056,"t":1794376324000,"rx":1794376336865},{"id":"EAL-1157","dir":0,"lat":25.012505,"lon":121.460613,"t":1794376327000,"rx":1794376336865},{"id":"EAL-1161","dir":0,"lat":25.045907,"lon":121.518538,"t":1794376326000,"rx":1794376336865},{"id":"EAL-1162","dir":0,"lat":25.020785,"lon":121.498232,"t":1794376324000,"rx":1794376336865},{"id":"EAL-1168","dir":0,"lat":25.034345,"lon":121.50584,"t":1794376320000,"rx":1794376336865},{"id":"EAL-1171","dir":0,"lat":25.063148,"lon":121.566002,"t":1794376316000,"rx":1794376336865},{"id":"EAL-1173","dir":0,"lat":25.056792,"lon":121.569587,"t":1794376319000,"rx":1794376336865},{"id":"EAL-1299","dir":0,"lat":25.060698,"lon":121.567183,"t":1794376325000,"rx":1794376336865},{"id":"EAL-2266","dir":0,"lat":25.046323,"lon":121.51682,"t":1794376316000,"rx":1794376336865},{"id":"EAL-2267","dir":0,"lat":25.02739,"lon":121.500537,"t":1794376316000,"rx":1794376336865},{"id":"EAA-150","dir":0,"lat":25.05873,"lon":121.569182,"t":1794376337000,"rx":1794376361149},{"id":"EAA-151","dir":0,"lat":25.025178,"lon":121.451098,"t":1794376343000,"rx":1794376361149},{"id":"EAL-0072","dir":0,"lat":24.99987,"lon":121.49633,"t":1794376352000,"rx":1794376361149},{"id":"EAL-0080","dir":0,"lat":25.013507,"lon":121.456248,"t":1794376343000,"rx":1794376361149},{"id":"EAL-0081","dir":0,"lat":25.004965,"lon":121.480252,"t":1794376355000,"rx":1794376361149},{"id":"EAL-0097","dir":0,"lat":25.008358,"lon":121.466158,"t":1794376355000,"rx":1794376361149},{"id":"EAL-0236","dir":0,"lat":25.030125,"lon":121.50058,"t":1794376337000,"rx":1794376361149},{"id":"EAL-1157","dir":0,"lat":25.012538,"lon":121.460575,"t":1794376347000,"rx":1794376361149},{"id":"EAL-1161","dir":0,"lat":25.045912,"lon":121.518578,"t":1794376346000,"rx":1794376361149},{"id":"EAL-1162","dir":0,"lat":25.02134,"lon":121.498572,"t":1794376344000,"rx":1794376361149},{"id":"EAL-1168","dir":0,"lat":25.035133,"lon":121.506237,"t":1794376340000,"rx":1794376361149},{"id":"EAL-1171","dir":0,"lat":25.064713,"lon":121.565745,"t":1794376336000,"rx":1794376361149},{"id":"EAL-1173","dir":0,"lat":25.05836,"lon":121.569163,"t":1794376335000,"rx":1794376361149},{"id":"EAL-1299","dir":0,"lat":25.06159,"lon":121.566513,"t":1794376337000,"rx":1794376361149},{"id":"EAL-2266","dir":0,"lat":25.046218,"lon":121.51726,"t":1794376336000,"rx":1794376361149},{"id":"EAL-2267","dir":0,"lat":25.028607,"lon":121.500607,"t":1794376335000,"rx":1794376361149},{"id":"EAA-150","dir":0,"lat":25.059412,"lon":121.568432,"t":1794376377000,"rx":1794376385057},{"id":"EAA-151","dir":0,"lat":25.026647,"lon":121.45269,"t":1794376376000,"rx":1794376385057},{"id":"EAL-0072","dir":0,"lat":24.99987,"lon":121.496332,"t":1794376372000,"rx":1794376385057},{"id":"EAL-0080","dir":0,"lat":25.013448,"lon":121.456228,"t":1794376363000,"rx":1794376385057},{"id":"EAL-0081","dir":0,"lat":25.004752,"lon":121.480343,"t":1794376375000,"rx":1794376385057},{"id":"EAL-0097","dir":0,"lat":25.008068,"lon":121.467025,"t":1794376375000,"rx":1794376385057},{"id":"EAL-0236","dir":0,"lat":25.030977,"lon":121.50067,"t":1794376377000,"rx":1794376385057},{"id":"EAL-1157","dir":0,"lat":25.012542,"lon":121.460582,"t":1794376367000,"rx":1794376385057},{"id":"EAL-1161","dir":0,"lat":25.045925,"lon":121.51858,"t":1794376366000,"rx":1794376385057},{"id":"EAL-1162","dir":0,"lat":25.021318,"lon":121.498592,"t":1794376364000,"rx":1794376385057},{"id":"EAL-1168","dir":0,"lat":25.035303,"lon":121.506512,"t":1794376360000,"rx":1794376385057},{"id":"EAL-1171","dir":0,"lat":25.066345,"lon":121.565332,"t":1794376376000,"rx":1794376385057},{"id":"EAL-1173","dir":0,"lat":25.059425,"lon":121.568835,"t":1794376376000,"rx":1794376385057},{"id":"EAL-2266","dir":0,"lat":25.04632,"lon":121.51742,"t":1794376376000,"rx":1794376385057},{"id":"EAL-2267","dir":0,"lat":25.029022,"lon":121.500572,"t":1794376375000,"rx":1794376385057},{"id":"EAA-150","dir":0,"lat":25.059633,"lon":121.568157,"t":1794376397000,"rx":1794376411094},{"id":"EAA-151","dir":0,"lat":25.026012,"lon":121.453727,"t":1794376392000,"rx":1794376411094},{"id":"EAL-0072","dir":0,"lat":25.000522,"lon":121.496747,"t":1794376392000,"rx":1794376411094},{"id":"EAL-0080","dir":0,"lat":25.012007,"lon":121.455982,"t":1794376403000,"rx":1794376411094},{"id":"EAL-0081","dir":0,"lat":25.004727,"lon":121.480345,"t":1794376395000,"rx":1794376411094},{"id":"EAL-0097","dir":0,"lat":25.007622,"lon":121.468245,"t":1794376395000,"rx":1794376411094},{"id":"EAL-0236","dir":0,"lat":25.030982,"lon":121.500665,"t":1794376397000,"rx":1794376411094},{"id":"EAL-1157","dir":0,"lat":25.012537,"lon":121.460565,"t":1794376387000,"rx":1794376411094},{"id":"EAL-1161","dir":0,"lat":25.045843,"lon":121.518893,"t":1794376386000,"rx":1794376411094},{"id":"EAL-1162","dir":0,"lat":25.021295,"lon":121.498583,"t":1794376404000,"rx":1794376411094},{"id":"EAL-1168","dir":0,"lat":25.035363,"lon":121.506502,"t":1794376400000,"rx":1794376411094},{"id":"EAL-1171","dir":0,"lat":25.066272,"lon":121.565315,"t":1794376396000,"rx":1794376411094},{"id":"EAL-1173","dir":0,"lat":25.059388,"lon":121.568493,"t":1794376396000,"rx":1794376411094},{"id":"EAL-2266","dir":0,"lat":25.046328,"lon":121.517353,"t":1794376396000,"rx":1794376411094},{"id":"EAL-2267","dir":0,"lat":25.029067,"lon":121.500542,"t":1794376395000,"rx":1794376411094},{"id":"EAA-150","dir":0,"lat":25.059998,"lon":121.567807,"t":1794376417000,"rx":1794376433833},{"id":"EAA-151","dir":0,"lat":25.025987,"lon":121.453792,"t":1794376412000,"rx":1794376433833},{"id":"EAL-0072","dir":0,"lat":25.000717,"lon":121.496833,"t":1794376412000,"rx":1794376433833},{"id":"EAL-0080","dir":0,"lat":25.0117,"lon":121.455297,"t":1794376423000,"rx":1794376433833},{"id":"EAL-0081","dir":0,"lat":25.00411,"lon":121.480668,"t":1794376417000,"rx":1794376433833},{"id":"EAL-0097","dir":0,"lat":25.007517,"lon":121.468357,"t":1794376415000,"rx":1794376433833},{"id":"EAL-0236","dir":0,"lat":25.030985,"lon":121.500662,"t":1794376417000,"rx":1794376433833},{"id":"EAL-1157","dir":0,"lat":25.012532,"lon":121.460598,"t":1794376407000,"rx":1794376433833},{"id":"EAL-1161","dir":0,"lat":25.045408,"lon":121.520623,"t":1794376406000,"rx":1794376433833},{"id":"EAL-1162","dir":0,"lat":25.021303,"lon":121.498553,"t":1794376424000,"rx":1794376433833},{"id":"EAL-1168","dir":0,"lat":25.035762,"lon":121.506623,"t":1794376420000,"rx":1794376433833},{"id":"EAL-1171","dir":0,"lat":25.066225,"lon":121.565328,"t":1794376416000,"rx":1794376433833},{"id":"EAL-1173","dir":0,"lat":25.059442,"lon":121.56833,"t":1794376416000,"rx":1794376433833},{"id":"EAL-2266","dir":0,"lat":25.046207,"lon":121.517463,"t":1794376416000,"rx":1794376433833},{"id":"EAL-2267","dir":0,"lat":25.02907,"lon":121.500542,"t":1794376415000,"rx":1794376433833},{"id":"EAA-150","dir":0,"lat":25.06048,"lon":121.567325,"t":1794376437000,"rx":1794376458813},{"id":"EAA-151","dir":0,"lat":25.024712,"lon":121.454343,"t":1794376439000,"rx":1794376458813},{"id":"EAL-0072","dir":0,"lat":25.000717,"lon":121.49683,"t":1794376432000,"rx":1794376458813},{"id":"EAL-0080","dir":0,"lat":25.011668,"lon":121.455238,"t":1794376443000,"rx":1794376458813},{"id":"EAL-0081","dir":0,"lat":25.003112,"lon":121.48117,"t":1794376448000,"rx":1794376458813},{"id":"EAL-0097","dir":0,"lat":25.00718,"lon":121.469598,"t":1794376443000,"rx":1794376458813},{"id":"EAL-0236","dir":0,"lat":25.030987,"lon":121.50066,"t":1794376437000,"rx":1794376458813},{"id":"EAL-1157","dir":0,"lat":25.01313,"lon":121.460685,"t":1794376447000,"rx":1794376458813},{"id":"EAL-1161","dir":0,"lat":25.04501,"lon":121.521547,"t":1794376446000,"rx":1794376458813},{"id":"EAL-1162","dir":0,"lat":25.022583,"lon":121.499393,"t":1794376444000,"rx":1794376458813},{"id":"EAL-1168","dir":0,"lat":25.035812,"lon":121.506638,"t":1794376440000,"rx":1794376458813},{"id":"EAL-1171","dir":0,"lat":25.066273,"lon":121.565315,"t":1794376436000,"rx":1794376458813},{"id":"EAL-1173","dir":0,"lat":25.060202,"lon":121.567658,"t":1794376436000,"rx":1794376458813},{"id":"EAL-2266","dir":0,"lat":25.04594,"lon":121.518138,"t":1794376436000,"rx":1794376458813},{"id":"EAL-2267","dir":0,"lat":25.029955,"lon":121.50058,"t":1794376448000,"rx":1794376458813},{"id":"EAA-150","dir":0,"lat":25.061615,"lon":121.56652,"t":1794376475000,"rx":1794376482286},{"id":"EAA-151","dir":0,"lat":25.023498,"lon":121.454278,"t":1794376472000,"rx":1794376482286},{"id":"EAL-0072","dir":0,"lat":25.000688,"lon":121.496852,"t":1794376472000,"rx":1794376482286},{"id":"EAL-0080","dir":0,"lat":25.011717,"lon":121.456025,"t":1794376463000,"rx":1794376482286},{"id":"EAL-0081","dir":0,"lat":25.000437,"lon":121.481342,"t":1794376475000,"rx":1794376482286},{"id":"EAL-0097","dir":0,"lat":25.006812,"lon":121.470603,"t":1794376475000,"rx":1794376482286},{"id":"EAL-0236","dir":0,"lat":25.030988,"lon":121.500658,"t":1794376457000,"rx":1794376482286},{"id":"EAL-1157","dir":0,"lat":25.013498,"lon":121.461008,"t":1794376467000,"rx":1794376482286},{"id":"EAL-1161","dir":0,"lat":25.044457,"lon":121.52144,"t":1794376466000,"rx":1794376482286},{"id":"EAL-1162","dir":0,"lat":25.023778,"lon":121.500135,"t":1794376464000,"rx":1794376482286},{"id":"EAL-1168","dir":0,"lat":25.035817,"lon":121.506645,"t":1794376460000,"rx":1794376482286},{"id":"EAL-1171","dir":0,"lat":25.066263,"lon":121.565303,"t":1794376456000,"rx":1794376482286},{"id":"EAL-1173","dir":0,"lat":25.060705,"lon":121.567228,"t":1794376472000,"rx":1794376482286},{"id":"EAL-1299","dir":0,"lat":25.063257,"lon":121.565918,"t":1794376468000,"rx":1794376482286},{"id":"EAL-2266","dir":0,"lat":25.045887,"lon":121.518358,"t":1794376456000,"rx":1794376482286},{"id":"EAL-2267","dir":0,"lat":25.030373,"lon":121.500565,"t":1794376455000,"rx":1794376482286},{"id":"EAA-150","dir":0,"lat":25.063425,"lon":121.565975,"t":1794376497000,"rx":1794376506593},{"id":"EAA-151","dir":0,"lat":25.022485,"lon":121.454247,"t":1794376492000,"rx":1794376506593},{"id":"EAL-0072","dir":0,"lat":25.000972,"lon":121.497045,"t":1794376492000,"rx":1794376506593},{"id":"EAL-0080","dir":0,"lat":25.0112,"lon":121.456898,"t":1794376483000,"rx":1794376506593},{"id":"EAL-0081","dir":0,"lat":25.00042,"lon":121.481342,"t":1794376495000,"rx":1794376506593},{"id":"EAL-0097","dir":0,"lat":25.006827,"lon":121.470642,"t":1794376495000,"rx":1794376506593},{"id":"EAL-0236","dir":0,"lat":25.031235,"lon":121.502107,"t":1794376497000,"rx":1794376506593},{"id":"EAL-1157","dir":0,"lat":25.014158,"lon":121.461093,"t":1794376487000,"rx":1794376506593},{"id":"EAL-1161","dir":0,"lat":25.044252,"lon":121.521363,"t":1794376486000,"rx":1794376506593},{"id":"EAL-1162","dir":0,"lat":25.025157,"lon":121.50049,"t":1794376492000,"rx":1794376506593},{"id":"EAL-1168","dir":0,"lat":25.035868,"lon":121.506652,"t":1794376480000,"rx":1794376506593},{"id":"EAL-1171","dir":0,"lat":25.066225,"lon":121.56533,"t":1794376496000,"rx":1794376506593},{"id":"EAL-1173","dir":0,"lat":25.062175,"lon":121.566303,"t":1794376496000,"rx":1794376506593},{"id":"EAL-2266","dir":0,"lat":25.045893,"lon":121.518372,"t":1794376496000,"rx":1794376506593},{"id":"EAL-2267","dir":0,"lat":25.0312,"lon":121.500628,"t":1794376495000,"rx":1794376506593},{"id":"EAA-150","dir":0,"lat":25.064328,"lon":121.565798,"t":1794376517000,"rx":1794376529420},{"id":"EAA-151","dir":0,"lat":25.020903,"lon":121.454662,"t":1794376512000,"rx":1794376529420},{"id":"EAL-0072","dir":0,"lat":25.00119,"lon":121.49683,"t":1794376512000,"rx":1794376529420},{"id":"EAL-0080","dir":0,"lat":25.01038,"lon":121.457722,"t":1794376513000,"rx":1794376529420},{"id":"EAL-0081","dir":0,"lat":24.999433,"lon":121.481293,"t":1794376515000,"rx":1794376529420},{"id":"EAL-0097","dir":0,"lat":25.006645,"lon":121.471323,"t":1794376518000,"rx":1794376529420},{"id":"EAL-0236","dir":0,"lat":25.031288,"lon":121.502017,"t":1794376517000,"rx":1794376529420},{"id":"EAL-1157","dir":0,"lat":25.014088,"lon":121.461115,"t":1794376507000,"rx":1794376529420},{"id":"EAL-1161","dir":0,"lat":25.043922,"lon":121.522043,"t":1794376514000,"rx":1794376529420},{"id":"EAL-1162","dir":0,"lat":25.025678,"lon":121.50057,"t":1794376504000,"rx":1794376529420},{"id":"EAL-1168","dir":0,"lat":25.036838,"lon":121.506752,"t":1794376504000,"rx":1794376529420},{"id":"EAL-1171","dir":0,"lat":25.066283,"lon":121.565308,"t":1794376516000,"rx":1794376529420},{"id":"EAL-1173","dir":0,"lat":25.06238,"lon":121.56624,"t":1794376516000,"rx":1794376529420},{"id":"EAL-2266","dir":0,"lat":25.045898,"lon":121.518372,"t":1794376516000,"rx":1794376529420},{"id":"EAL-2267","dir":0,"lat":25.031277,"lon":121.501585,"t":1794376515000,"rx":1794376529420},{"id":"EAA-150","dir":0,"lat":25.065298,"lon":121.565663,"t":1794376537000,"rx":1794376554833},{"id":"EAA-151","dir":0,"lat":25.01961,"lon":121.45531,"t":1794376532000,"rx":1794376554833},{"id":"EAL-0072","dir":0,"lat":25.001265,"lon":121.496805,"t":1794376532000,"rx":1794376554833},{"id":"EAL-0080","dir":0,"lat":25.00963,"lon":121.457697,"t":1794376527000,"rx":1794376554833},{"id":"EAL-0081","dir":0,"lat":24.999415,"lon":121.481278,"t":1794376535000,"rx":1794376554833},{"id":"EAL-0097","dir":0,"lat":25.00644,"lon":121.472447,"t":1794376535000,"rx":1794376554833},{"id":"EAL-0236","dir":0,"lat":25.031325,"lon":121.503083,"t":1794376537000,"rx":1794376554833},{"id":"EAL-1157","dir":0,"lat":25.01411,"lon":121.461105,"t":1794376527000,"rx":1794376554833},{"id":"EAL-1161","dir":0,"lat":25.043822,"lon":121.522255,"t":1794376526000,"rx":1794376554833},{"id":"EAL-1162","dir":0,"lat":25.026127,"lon":121.500577,"t":1794376524000,"rx":1794376554833},{"id":"EAL-1168","dir":0,"lat":25.037918,"lon":121.507217,"t":1794376520000,"rx":1794376554833},{"id":"EAL-1171","dir":0,"lat":25.066277,"lon":121.56531,"t":1794376536000,"rx":1794376554833},{"id":"EAL-1173","dir":0,"lat":25.063053,"lon":121.566007,"t":1794376536000,"rx":1794376554833},{"id":"EAL-2266","dir":0,"lat":25.045863,"lon":121.518565,"t":1794376536000,"rx":1794376554833},{"id":"EAL-2267","dir":0,"lat":25.031287,"lon":121.503078,"t":1794376535000,"rx":1794376554833},{"id":"EAA-150","dir":0,"lat":25.067063,"lon":121.565022,"t":1794376557000,"rx":1794376578225},{"id":"EAA-151","dir":0,"lat":25.019537,"lon":121.455328,"t":1794376552000,"rx":1794376578225},{"id":"EAL-0072","dir":0,"lat":25.001813,"lon":121.49667,"t":1794376552000,"rx":1794376578225},{"id":"EAL-0080","dir":0,"lat":25.009573,"lon":121.457743,"t":1794376563000,"rx":1794376578225},{"id":"EAL-0081","dir":0,"lat":24.99886,"lon":121.48127,"t":1794376557000,"rx":1794376578225},{"id":"EAL-0097","dir":0,"lat":25.006345,"lon":121.4731,"t":1794376555000,"rx":1794376578225},{"id":"EAL-0098","dir":0,"lat":25.024512,"lon":121.450347,"t":1794376569000,"rx":1794376578225},{"id":"EAL-0236","dir":0,"lat":25.031905,"lon":121.504515,"t":1794376567000,"rx":1794376578225},{"id":"EAL-1157","dir":0,"lat":25.014067,"lon":121.46137,"t":1794376568000,"rx":1794376578225},{"id":"EAL-1161","dir":0,"lat":25.043773,"lon":121.522475,"t":1794376566000,"rx":1794376578225},{"id":"EAL-1162","dir":0,"lat":25.026132,"lon":121.500558,"t":1794376564000,"rx":1794376578225},{"id":"EAL-1168","dir":0,"lat":25.041127,"lon":121.507905,"t":1794376560000,"rx":1794376578225},{"id":"EAL-1171","dir":0,"lat":25.066277,"lon":121.565318,"t":1794376556000,"rx":1794376578225},{"id":"EAL-1173","dir":0,"lat":25.06307,"lon":121.565962,"t":1794376556000,"rx":1794376578225},{"id":"EAL-2266","dir":0,"lat":25.045288,"lon":121.520593,"t":1794376562000,"rx":1794376578225},{"id":"EAL-2267","dir":0,"lat":25.031967,"lon":121.504482,"t":1794376558000,"rx":1794376578225},{"id":"EAA-150","dir":0,"lat":25.067893,"lon":121.56643,"t":1794376577000,"rx":1794376602158},{"id":"EAA-151","dir":0,"lat":25.017785,"lon":121.456123,"t":1794376592000,"rx":1794376602158},{"id":"EAL-0072","dir":0,"lat":25.002042,"lon":121.496685,"t":1794376592000,"rx":1794376602158},{"id":"EAL-0080","dir":0,"lat":25.009607,"lon":121.457738,"t":1794376583000,"rx":1794376602158},{"id":"EAL-0081","dir":0,"lat":24.997647,"lon":121.48133,"t":1794376595000,"rx":1794376602158},{"id":"EAL-0097","dir":0,"lat":25.006312,"lon":121.47337,"t":1794376595000,"rx":1794376602158},{"id":"EAL-0098","dir":0,"lat":25.025615,"lon":121.451493,"t":1794376589000,"rx":1794376602158},{"id":"EAL-0236","dir":0,"lat":25.032603,"lon":121.50481,"t":1794376577000,"rx":1794376602158},{"id":"EAL-1157","dir":0,"lat":25.013857,"lon":121.462022,"t":1794376588000,"rx":1794376602158},{"id":"EAL-1161","dir":0,"lat":25.043717,"lon":121.522693,"t":1794376586000,"rx":1794376602158},{"id":"EAL-1162","dir":0,"lat":25.027415,"lon":121.500547,"t":1794376585000,"rx":1794376602158},{"id":"EAL-1168","dir":0,"lat":25.042023,"lon":121.508528,"t":1794376580000,"rx":1794376602158},{"id":"EAL-1171","dir":0,"lat":25.066237,"lon":121.565318,"t":1794376576000,"rx":1794376602158},{"id":"EAL-1173","dir":0,"lat":25.063088,"lon":121.56595,"t":1794376576000,"rx":1794376602158},{"id":"EAL-2266","dir":0,"lat":25.045228,"lon":121.521457,"t":1794376576000,"rx":1794376602158},{"id":"EAL-2267","dir":0,"lat":25.032868,"lon":121.504952,"t":1794376576000,"rx":1794376602158},{"id":"EAA-150","dir":0,"lat":25.067718,"lon":121.566972,"t":1794376617000,"rx":1794376626573},{"id":"EAA-151","dir":0,"lat":25.017225,"lon":121.456398,"t":1794376612000,"rx":1794376626573},{"id":"EAL-0072","dir":0,"lat":25.002037,"lon":121.49666,"t":1794376612000,"rx":1794376626573},{"id":"EAL-0080","dir":0,"lat":25.009148,"lon":121.458395,"t":1794376603000,"rx":1794376626573},{"id":"EAL-0081","dir":0,"lat":24.997527,"lon":121.481328,"t":1794376615000,"rx":1794376626573},{"id":"EAL-0097","dir":0,"lat":25.006313,"lon":121.473372,"t":1794376615000,"rx":1794376626573},{"id":"EAL-0098","dir":0,"lat":25.026673,"lon":121.45261,"t":1794376609000,"rx":1794376626573},{"id":"EAL-0236","dir":0,"lat":25.033023,"lon":121.504915,"t":1794376617000,"rx":1794376626573},{"id":"EAL-1157","dir":0,"lat":25.013858,"lon":121.461953,"t":1794376607000,"rx":1794376626573},{"id":"EAL-1161","dir":0,"lat":25.044583,"lon":121.523305,"t":1794376618000,"rx":1794376626573},{"id":"EAL-1162","dir":0,"lat":25.029962,"lon":121.500607,"t":1794376615000,"rx":1794376626573},{"id":"EAL-1168","dir":0,"lat":25.042038,"lon":121.508462,"t":1794376600000,"rx":1794376626573},{"id":"EAL-1171","dir":0,"lat":25.066277,"lon":121.565335,"t":1794376616000,"rx":1794376626573},{"id":"EAL-1173","dir":0,"lat":25.063398,"lon":121.565913,"t":1794376616000,"rx":1794376626573},{"id":"EAL-2266","dir":0,"lat":25.044745,"lon":121.521528,"t":1794376616000,"rx":1794376626573},{"id":"EAL-2267","dir":0,"lat":25.032935,"lon":121.504953,"t":1794376615000,"rx":1794376626573},{"id":"EAA-150","dir":0,"lat":25.067822,"lon":121.567165,"t":1794376637000,"rx":1794376650697},{"id":"EAA-151","dir":0,"lat":25.017152,"lon":121.456422,"t":1794376632000,"rx":1794376650697},{"id":"EAL-0072","dir":0,"lat":25.002037,"lon":121.496663,"t":1794376632000,"rx":1794376650697},{"id":"EAL-0080","dir":0,"lat":25.009238,"lon":121.458897,"t":1794376623000,"rx":1794376650697},{"id":"EAL-0081","dir":0,"lat":24.997243,"lon":121.48131,"t":1794376635000,"rx":1794376650697},{"id":"EAL-0097","dir":0,"lat":25.006258,"lon":121.473587,"t":1794376635000,"rx":1794376650697},{"id":"EAL-0098","dir":0,"lat":25.025808,"lon":121.453918,"t":1794376629000,"rx":1794376650697},{"id":"EAL-0236","dir":0,"lat":25.034347,"lon":121.505817,"t":1794376637000,"rx":1794376650697},{"id":"EAL-1157","dir":0,"lat":25.013212,"lon":121.462778,"t":1794376627000,"rx":1794376650697},{"id":"EAL-1161","dir":0,"lat":25.045758,"lon":121.523625,"t":1794376632000,"rx":1794376650697},{"id":"EAL-1162","dir":0,"lat":25.03072,"lon":121.500622,"t":1794376624000,"rx":1794376650697},{"id":"EAL-1168","dir":0,"lat":25.042047,"lon":121.508465,"t":1794376620000,"rx":1794376650697},{"id":"EAL-1171","dir":0,"lat":25.0663,"lon":121.565328,"t":1794376636000,"rx":1794376650697},{"id":"EAL-1173","dir":0,"lat":25.065032,"lon":121.565705,"t":1794376636000,"rx":1794376650697},{"id":"EAL-2266","dir":0,"lat":25.044308,"lon":121.521395,"t":1794376636000,"rx":1794376650697},{"id":"EAL-2267","dir":0,"lat":25.03422,"lon":121.505698,"t":1794376639000,"rx":1794376650697},{"id":"EAL-0072","dir":0,"lat":25.00226,"lon":121.49668,"t":1794376642000,"rx":1794376660769},{"id":"EAL-0080","dir":0,"lat":25.009105,"lon":121.458972,"t":1794376643000,"rx":1794376660769},{"id":"EAL-0081","dir":0,"lat":24.996662,"lon":121.4812,"t":1794376642000,"rx":1794376660769},{"id":"EAL-0097","dir":0,"lat":25.006208,"lon":121.474172,"t":1794376641000,"rx":1794376660769},{"id":"EAL-1162","dir":0,"lat":25.031212,"lon":121.501358,"t":1794376644000,"rx":1794376660769},{"id":"EAL-1168","dir":0,"lat":25.042043,"lon":121.508465,"t":1794376640000,"rx":1794376660769},{"id":"EAA-151","dir":0,"lat":25.016953,"lon":121.456495,"t":1794376652000,"rx":1794376673569},{"id":"EAL-0072","dir":0,"lat":25.002938,"lon":121.496655,"t":1794376652000,"rx":1794376673569},{"id":"EAL-0080","dir":0,"lat":25.008793,"lon":121.459043,"t":1794376663000,"rx":1794376673569},{"id":"EAL-0081","dir":0,"lat":24.99593,"lon":121.48107,"t":1794376655000,"rx":1794376673569},{"id":"EAL-0097","dir":0,"lat":25.006007,"lon":121.475355,"t":1794376655000,"rx":1794376673569},{"id":"EAL-0098","dir":0,"lat":25.02473,"lon":121.454258,"t":1794376664000,"rx":1794376673569},{"id":"EAL-0236","dir":0,"lat":25.035543,"lon":121.506562,"t":1794376657000,"rx":1794376673569},{"id":"EAL-1157","dir":0,"lat":25.01287,"lon":121.463183,"t":1794376647000,"rx":1794376673569},{"id":"EAL-1161","dir":0,"lat":25.046697,"lon":121.523975,"t":1794376646000,"rx":1794376673569},{"id":"EAL-1162","dir":0,"lat":25.031248,"lon":121.502098,"t":1794376664000,"rx":1794376673569},{"id":"EAL-1168","dir":0,"lat":25.04204,"lon":121.508462,"t":1794376660000,"rx":1794376673569},{"id":"EAL-1171","dir":0,"lat":25.066293,"lon":121.565313,"t":1794376656000,"rx":1794376673569},{"id":"EAL-2266","dir":0,"lat":25.043867,"lon":121.52203,"t":1794376661000,"rx":1794376673569},{"id":"EAL-2267","dir":0,"lat":25.035275,"lon":121.506352,"t":1794376655000,"rx":1794376673569},{"id":"EAA-151","dir":0,"lat":25.016898,"lon":121.456438,"t":1794376672000,"rx":1794376698283},{"id":"EAL-0072","dir":0,"lat":25.003857,"lon":121.496495,"t":1794376685000,"rx":1794376698283},{"id":"EAL-0080","dir":0,"lat":25.008785,"lon":121.459032,"t":1794376683000,"rx":1794376698283},{"id":"EAL-0081","dir":0,"lat":24.995352,"lon":121.481022,"t":1794376677000,"rx":1794376698283},{"id":"EAL-0097","dir":0,"lat":25.005907,"lon":121.475925,"t":1794376675000,"rx":1794376698283},{"id":"EAL-0098","dir":0,"lat":25.024597,"lon":121.454345,"t":1794376689000,"rx":1794376698283},{"id":"EAL-0236","dir":0,"lat":25.0355,"lon":121.506592,"t":1794376677000,"rx":1794376698283},{"id":"EAL-1157","dir":0,"lat":25.01327,"lon":121.46244,"t":1794376687000,"rx":1794376698283},{"id":"EAL-1161","dir":0,"lat":25.04717,"lon":121.524188,"t":1794376686000,"rx":1794376698283},{"id":"EAL-1162","dir":0,"lat":25.031265,"lon":121.503183,"t":1794376684000,"rx":1794376698283},{"id":"EAL-1168","dir":0,"lat":25.042033,"lon":121.508458,"t":1794376680000,"rx":1794376698283},{"id":"EAL-1171","dir":0,"lat":25.066305,"lon":121.565338,"t":1794376676000,"rx":1794376698283},{"id":"EAL-2266","dir":0,"lat":25.043772,"lon":121.522322,"t":1794376676000,"rx":1794376698283},{"id":"EAL-2267","dir":0,"lat":25.035602,"lon":121.50654,"t":1794376675000,"rx":1794376698283},{"id":"EAA-151","dir":0,"lat":25.015892,"lon":121.456662,"t":1794376712000,"rx":1794376721477},{"id":"EAL-0072","dir":0,"lat":25.00683,"lon":121.49545,"t":1794376712000,"rx":1794376721477},{"id":"EAL-0080","dir":0,"lat":25.00871,"lon":121.459063,"t":1794376703000,"rx":1794376721477},{"id":"EAL-0081","dir":0,"lat":24.995688,"lon":121.48295,"t":1794376715000,"rx":1794376721477},{"id":"EAL-0097","dir":0,"lat":25.00577,"lon":121.478402,"t":1794376715000,"rx":1794376721477},{"id":"EAL-0098","dir":0,"lat":25.023482,"lon":121.45426,"t":1794376709000,"rx":1794376721477},{"id":"EAL-0236","dir":0,"lat":25.036837,"lon":121.506855,"t":1794376712000,"rx":1794376721477},{"id":"EAL-1157","dir":0,"lat":25.013172,"lon":121.462725,"t":1794376707000,"rx":1794376721477},{"id":"EAL-1161","dir":0,"lat":25.047862,"lon":121.524337,"t":1794376709000,"rx":1794376721477},{"id":"EAL-1162","dir":0,"lat":25.031315,"lon":121.504045,"t":1794376704000,"rx":1794376721477},{"id":"EAL-1168","dir":0,"lat":25.042283,"lon":121.508502,"t":1794376700000,"rx":1794376721477},{"id":"EAL-1171","dir":0,"lat":25.066243,"lon":121.565313,"t":1794376696000,"rx":1794376721477},{"id":"EAL-2266","dir":0,"lat":25.043608,"lon":121.522773,"t":1794376696000,"rx":1794376721477},{"id":"EAL-2267","dir":0,"lat":25.035668,"lon":121.506603,"t":1794376695000,"rx":1794376721477},{"id":"EAA-151","dir":0,"lat":25.015292,"lon":121.456592,"t":1794376732000,"rx":1794376744644},{"id":"EAL-0072","dir":0,"lat":25.00971,"lon":121.495428,"t":1794376732000,"rx":1794376744644},{"id":"EAL-0080","dir":0,"lat":25.008705,"lon":121.459072,"t":1794376723000,"rx":1794376744644},{"id":"EAL-0081","dir":0,"lat":24.99623,"lon":121.483787,"t":1794376735000,"rx":1794376744644},{"id":"EAL-0097","dir":0,"lat":25.005823,"lon":121.478637,"t":1794376735000,"rx":1794376744644},{"id":"EAL-0098","dir":0,"lat":25.022485,"lon":121.454253,"t":1794376729000,"rx":1794376744644},{"id":"EAL-0236","dir":0,"lat":25.036973,"lon":121.506918,"t":1794376717000,"rx":1794376744644},{"id":"EAL-1157","dir":0,"lat":25.013343,"lon":121.462128,"t":1794376727000,"rx":1794376744644},{"id":"EAL-1161","dir":0,"lat":25.048097,"lon":121.52436,"t":1794376726000,"rx":1794376744644},{"id":"EAL-1162","dir":0,"lat":25.03222,"lon":121.504615,"t":1794376724000,"rx":1794376744644},{"id":"EAL-1168","dir":0,"lat":25.044895,"lon":121.509172,"t":1794376732000,"rx":1794376744644},{"id":"EAL-1171","dir":0,"lat":25.066278,"lon":121.565367,"t":1794376716000,"rx":1794376744644},{"id":"EAL-2266","dir":0,"lat":25.043603,"lon":121.522793,"t":1794376716000,"rx":1794376744644},{"id":"EAL-2267","dir":0,"lat":25.03855,"lon":121.507368,"t":1794376735000,"rx":1794376744644},{"id":"EAA-151","dir":0,"lat":25.01526,"lon":121.456568,"t":1794376752000,"rx":1794376768678},{"id":"EAL-0072","dir":0,"lat":25.012643,"lon":121.495483,"t":1794376752000,"rx":1794376768678},{"id":"EAL-0080","dir":0,"lat":25.008637,"lon":121.459043,"t":1794376743000,"rx":1794376768678},{"id":"EAL-0081","dir":0,"lat":24.996267,"lon":121.484248,"t":1794376755000,"rx":1794376768678},{"id":"EAL-0097","dir":0,"lat":25.00606,"lon":121.479387,"t":1794376755000,"rx":1794376768678},{"id":"EAL-0098","dir":0,"lat":25.020953,"lon":121.454708,"t":1794376749000,"rx":1794376768678},{"id":"EAL-0236","dir":0,"lat":25.041637,"lon":121.508307,"t":1794376757000,"rx":1794376768678},{"id":"EAL-1157","dir":0,"lat":25.013522,"lon":121.462323,"t":1794376747000,"rx":1794376768678},{"id":"EAL-1161","dir":0,"lat":25.048952,"lon":121.524532,"t":1794376746000,"rx":1794376768678},{"id":"EAL-1162","dir":0,"lat":25.033663,"lon":121.505415,"t":1794376755000,"rx":1794376768678},{"id":"EAL-1168","dir":0,"lat":25.04536,"lon":121.509317,"t":1794376740000,"rx":1794376768678},{"id":"EAL-1171","dir":0,"lat":25.067662,"lon":121.565717,"t":1794376756000,"rx":1794376768678},{"id":"EAL-2266","dir":0,"lat":25.044235,"lon":121.523228,"t":1794376756000,"rx":1794376768678},{"id":"EAL-2267","dir":0,"lat":25.040597,"lon":121.507945,"t":1794376755000,"rx":1794376768678},{"id":"EAA-151","dir":0,"lat":25.01336,"lon":121.456252,"t":1794376772000,"rx":1794376783462},{"id":"EAL-0072","dir":0,"lat":25.015227,"lon":121.495545,"t":1794376772000,"rx":1794376783462},{"id":"EAL-0080","dir":0,"lat":25.008532,"lon":121.45917,"t":1794376763000,"rx":1794376783462},{"id":"EAL-0081","dir":0,"lat":24.996195,"lon":121.484835,"t":1794376775000,"rx":1794376783462},{"id":"EAL-0097","dir":0,"lat":25.00607,"lon":121.47939,"t":1794376775000,"rx":1794376783462},{"id":"EAL-0098","dir":0,"lat":25.02051,"lon":121.454665,"t":1794376769000,"rx":1794376783462},{"id":"EAL-1157","dir":0,"lat":25.013225,"lon":121.462832,"t":1794376767000,"rx":1794376783462},{"id":"EAL-1161","dir":0,"lat":25.049682,"lon":121.524707,"t":1794376766000,"rx":1794376783462},{"id":"EAL-1162","dir":0,"lat":25.034203,"lon":121.505765,"t":1794376764000,"rx":1794376783462},{"id":"EAL-1168","dir":0,"lat":25.046507,"lon":121.5097,"t":1794376775000,"rx":1794376783462},{"id":"EAL-2266","dir":0,"lat":25.044625,"lon":121.523322,"t":1794376761000,"rx":1794376783462},{"id":"EAA-151","dir":0,"lat":25.012873,"lon":121.45624,"t":1794376792000,"rx":1794376806394},{"id":"EAL-0072","dir":0,"lat":25.017622,"lon":121.496178,"t":1794376792000,"rx":1794376806394},{"id":"EAL-0080","dir":0,"lat":25.008923,"lon":121.459678,"t":1794376791000,"rx":1794376806394},{"id":"EAL-0081","dir":0,"lat":24.996248,"lon":121.484938,"t":1794376795000,"rx":1794376806394},{"id":"EAL-0097","dir":0,"lat":25.005807,"lon":121.48,"t":1794376795000,"rx":1794376806394},{"id":"EAL-0098","dir":0,"lat":25.020512,"lon":121.454773,"t":1794376789000,"rx":1794376806394},{"id":"EAL-0236","dir":0,"lat":25.042463,"lon":121.508598,"t":1794376797000,"rx":1794376806394},{"id":"EAL-1157","dir":0,"lat":25.012938,"lon":121.463503,"t":1794376793000,"rx":1794376806394},{"id":"EAL-1161","dir":0,"lat":25.050437,"lon":121.524967,"t":1794376786000,"rx":1794376806394},{"id":"EAL-1162","dir":0,"lat":25.035503,"lon":121.506477,"t":1794376784000,"rx":1794376806394},{"id":"EAL-1168","dir":0,"lat":25.046633,"lon":121.509588,"t":1794376780000,"rx":1794376806394},{"id":"EAL-1171","dir":0,"lat":25.067847,"lon":121.56708,"t":1794376796000,"rx":1794376806394},{"id":"EAL-2266","dir":0,"lat":25.047228,"lon":121.524125,"t":1794376796000,"rx":1794376806394},{"id":"EAL-2267","dir":0,"lat":25.042045,"lon":121.508407,"t":1794376796000,"rx":1794376806394},{"id":"EAA-151","dir":0,"lat":25.012428,"lon":121.456042,"t":1794376812000,"rx":1794376830243},{"id":"EAL-0072","dir":0,"lat":25.01949,"lon":121.497315,"t":1794376812000,"rx":1794376830243},{"id":"EAL-0080","dir":0,"lat":25.009838,"lon":121.460445,"t":1794376803000,"rx":1794376830243},{"id":"EAL-0081","dir":0,"lat":24.996217,"lon":121.485213,"t":1794376815000,"rx":1794376830243},{"id":"EAL-0097","dir":0,"lat":25.00491,"lon":121.480318,"t":1794376815000,"rx":1794376830243},{"id":"EAL-0098","dir":0,"lat":25.020047,"lon":121.455073,"t":1794376816000,"rx":1794376830243},{"id":"EAL-0236","dir":0,"lat":25.04432,"lon":121.50898,"t":1794376817000,"rx":1794376830243},{"id":"EAL-1157","dir":0,"lat":25.012087,"lon":121.464358,"t":1794376807000,"rx":1794376830243},{"id":"EAL-1161","dir":0,"lat":25.050458,"lon":121.524938,"t":1794376806000,"rx":1794376830243},{"id":"EAL-1162","dir":0,"lat":25.035742,"lon":121.506548,"t":1794376804000,"rx":1794376830243},{"id":"EAL-1168","dir":0,"lat":25.047685,"lon":121.510152,"t":1794376800000,"rx":1794376830243},{"id":"EAL-1171","dir":0,"lat":25.067768,"lon":121.567097,"t":1794376816000,"rx":1794376830243},{"id":"EAL-2266","dir":0,"lat":25.047517,"lon":121.524198,"t":1794376816000,"rx":1794376830243},{"id":"EAL-2267","dir":0,"lat":25.04215,"lon":121.508318,"t":1794376815000,"rx":1794376830243},{"id":"EAL-0080","dir":0,"lat":25.011427,"lon":121.461805,"t":1794376823000,"rx":1794376841065},{"id":"EAL-0098","dir":0,"lat":25.019512,"lon":121.45536,"t":1794376823000,"rx":1794376841065},{"id":"EAL-1161","dir":0,"lat":25.051232,"lon":121.525203,"t":1794376821000,"rx":1794376841065},{"id":"EAL-1162","dir":0,"lat":25.035818,"lon":121.506558,"t":1794376824000,"rx":1794376841065},{"id":"EAL-1168","dir":0,"lat":25.047695,"lon":121.510178,"t":1794376820000,"rx":1794376841065},{"id":"EAA-151","dir":0,"lat":25.012225,"lon":121.456058,"t":1794376838000,"rx":1794376854284},{"id":"EAL-0072","dir":0,"lat":25.01992,"lon":121.497608,"t":1794376832000,"rx":1794376854284},{"id":"EAL-0081","dir":0,"lat":24.996388,"lon":121.48664,"t":1794376835000,"rx":1794376854284},{"id":"EAL-0097","dir":0,"lat":25.004825,"lon":121.48036,"t":1794376835000,"rx":1794376854284},{"id":"EAL-0098","dir":0,"lat":25.019402,"lon":121.455353,"t":1794376829000,"rx":1794376854284},{"id":"EAL-0236","dir":0,"lat":25.045325,"lon":121.508767,"t":1794376837000,"rx":1794376854284},{"id":"EAL-1157","dir":0,"lat":25.0119,"lon":121.464507,"t":1794376827000,"rx":1794376854284},{"id":"EAL-1161","dir":0,"lat":25.051322,"lon":121.52519,"t":1794376826000,"rx":1794376854284},{"id":"EAL-1171","dir":0,"lat":25.06775,"lon":121.5671,"t":1794376836000,"rx":1794376854284},{"id":"EAL-2266","dir":0,"lat":25.047528,"lon":121.524198,"t":1794376836000,"rx":1794376854284},{"id":"EAL-2267","dir":0,"lat":25.042133,"lon":121.508337,"t":1794376835000,"rx":1794376854284},{"id":"EAA-151","dir":0,"lat":25.012003,"lon":121.45598,"t":1794376852000,"rx":1794376864933},{"id":"EAL-0072","dir":0,"lat":25.019917,"lon":121.49761,"t":1794376852000,"rx":1794376864933},{"id":"EAL-0080","dir":0,"lat":25.011602,"lon":121.462012,"t":1794376843000,"rx":1794376864933},{"id":"EAL-0081","dir":0,"lat":24.996265,"lon":121.488313,"t":1794376855000,"rx":1794376864933},{"id":"EAL-0097","dir":0,"lat":25.004803,"lon":121.480393,"t":1794376855000,"rx":1794376864933},{"id":"EAL-0098","dir":0,"lat":25.019307,"lon":121.455227,"t":1794376849000,"rx":1794376864933},{"id":"EAL-1157","dir":0,"lat":25.011752,"lon":121.464735,"t":1794376847000,"rx":1794376864933},{"id":"EAL-1161","dir":0,"lat":25.051495,"lon":121.525198,"t":1794376846000,"rx":1794376864933},{"id":"EAL-1162","dir":0,"lat":25.03581,"lon":121.506572,"t":1794376844000,"rx":1794376864933},{"id":"EAL-1168","dir":0,"lat":25.047697,"lon":121.510182,"t":1794376840000,"rx":1794376864933},{"id":"EAL-0236","dir":0,"lat":25.04512,"lon":121.509,"t":1794376857000,"rx":1794376878217},{"id":"EAL-1171","dir":0,"lat":25.067725,"lon":121.567098,"t":1794376856000,"rx":1794376878217},{"id":"EAL-2266","dir":0,"lat":25.047523,"lon":121.524198,"t":1794376856000,"rx":1794376878217},{"id":"EAL-2267","dir":0,"lat":25.042098,"lon":121.50834,"t":1794376855000,"rx":1794376878217},{"id":"EAA-151","dir":0,"lat":25.011758,"lon":121.45547,"t":1794376872000,"rx":1794376888910},{"id":"EAL-0072","dir":0,"lat":25.020633,"lon":121.498058,"t":1794376876000,"rx":1794376888910},{"id":"EAL-0080","dir":0,"lat":25.011612,"lon":121.462055,"t":1794376863000,"rx":1794376888910},{"id":"EAL-0081","dir":0,"lat":24.996298,"lon":121.489333,"t":1794376875000,"rx":1794376888910},{"id":"EAL-0097","dir":0,"lat":25.00397,"lon":121.48076,"t":1794376875000,"rx":1794376888910},{"id":"EAL-0098","dir":0,"lat":25.017717,"lon":121.45601,"t":1794376869000,"rx":1794376888910},{"id":"EAL-0236","dir":0,"lat":25.045057,"lon":121.509037,"t":1794376877000,"rx":1794376888910},{"id":"EAL-1157","dir":0,"lat":25.01174,"lon":121.464605,"t":1794376867000,"rx":1794376888910},{"id":"EAL-1161","dir":0,"lat":25.05149,"lon":121.525218,"t":1794376866000,"rx":1794376888910},{"id":"EAL-1162","dir":0,"lat":25.03581,"lon":121.506563,"t":1794376864000,"rx":1794376888910},{"id":"EAL-1168","dir":0,"lat":25.047697,"lon":121.510183,"t":1794376860000,"rx":1794376888910},{"id":"EAL-1171","dir":0,"lat":25.067712,"lon":121.567103,"t":1794376876000,"rx":1794376888910},{"id":"EAL-2266","dir":0,"lat":25.047525,"lon":121.524198,"t":1794376876000,"rx":1794376888910},{"id":"EAL-2267","dir":0,"lat":25.042118,"lon":121.508365,"t":1794376875000,"rx":1794376888910},{"id":"EAA-151","dir":0,"lat":25.011775,"lon":121.455477,"t":1794376892000,"rx":1794376912737},{"id":"EAL-0072","dir":0,"lat":25.020765,"lon":121.49811,"t":1794376892000,"rx":1794376912737},{"id":"EAL-0080","dir":0,"lat":25.012858,"lon":121.4604,"t":1794376903000,"rx":1794376912737},{"id":"EAL-0081","dir":0,"lat":24.99638,"lon":121.48979,"t":1794376895000,"rx":1794376912737},{"id":"EAL-0097","dir":0,"lat":25.003065,"lon":121.481197,"t":1794376898000,"rx":1794376912737},{"id":"EAL-0098","dir":0,"lat":25.017432,"lon":121.45622,"t":1794376889000,"rx":1794376912737},{"id":"EAL-0236","dir":0,"lat":25.044965,"lon":121.509047,"t":1794376897000,"rx":1794376912737},{"id":"EAL-1157","dir":0,"lat":25.011788,"lon":121.464972,"t":1794376887000,"rx":1794376912737},{"id":"EAL-1161","dir":0,"lat":25.051488,"lon":121.52522,"t":1794376886000,"rx":1794376912737},{"id":"EAL-1162","dir":0,"lat":25.037245,"lon":121.506927,"t":1794376904000,"rx":1794376912737},{"id":"EAL-1168","dir":0,"lat":25.047848,"lon":121.511622,"t":1794376900000,"rx":1794376912737},{"id":"EAL-1171","dir":0,"lat":25.067723,"lon":121.5671,"t":1794376896000,"rx":1794376912737},{"id":"EAL-2266","dir":0,"lat":25.047865,"lon":121.524288,"t":1794376898000,"rx":1794376912737},{"id":"EAL-2267","dir":0,"lat":25.042428,"lon":121.508482,"t":1794376895000,"rx":1794376912737},{"id":"EAA-151","dir":0,"lat":25.011483,"lon":121.455388,"t":1794376912000,"rx":1794376936930},{"id":"EAL-0072","dir":0,"lat":25.02247,"lon":121.499233,"t":1794376922000,"rx":1794376936930},{"id":"EAL-0080","dir":0,"lat":25.013412,"lon":121.46082,"t":1794376923000,"rx":1794376936930},{"id":"EAL-0081","dir":0,"lat":24.996728,"lon":121.491063,"t":1794376915000,"rx":1794376936930},{"id":"EAL-0097","dir":0,"lat":25.00149,"lon":121.481273,"t":1794376915000,"rx":1794376936930},{"id":"EAL-0098","dir":0,"lat":25.017293,"lon":121.456365,"t":1794376929000,"rx":1794376936930},{"id":"EAL-0236","dir":0,"lat":25.045575,"lon":121.509358,"t":1794376917000,"rx":1794376936930},{"id":"EAL-1157","dir":0,"lat":25.0117,"lon":121.464947,"t":1794376927000,"rx":1794376936930},{"id":"EAL-1161","dir":0,"lat":25.051498,"lon":121.525212,"t":1794376926000,"rx":1794376936930},{"id":"EAL-1162","dir":0,"lat":25.03929,"lon":121.507508,"t":1794376924000,"rx":1794376936930},{"id":"EAL-1168","dir":0,"lat":25.047407,"lon":121.512373,"t":1794376920000,"rx":1794376936930},{"id":"EAL-1171","dir":0,"lat":25.067733,"lon":121.567108,"t":1794376916000,"rx":1794376936930},{"id":"EAL-2266","dir":0,"lat":25.049628,"lon":121.52474,"t":1794376927000,"rx":1794376936930},{"id":"EAL-2267","dir":0,"lat":25.044948,"lon":121.50913,"t":1794376919000,"rx":1794376936930},{"id":"EAA-151","dir":0,"lat":25.011157,"lon":121.456808,"t":1794376952000,"rx":1794376962156},{"id":"EAL-0072","dir":0,"lat":25.024555,"lon":121.500367,"t":1794376952000,"rx":1794376962156},{"id":"EAL-0080","dir":0,"lat":25.014095,"lon":121.461243,"t":1794376943000,"rx":1794376962156},{"id":"EAL-0081","dir":0,"lat":24.996565,"lon":121.491002,"t":1794376955000,"rx":1794376962156},{"id":"EAL-0097","dir":0,"lat":25.000573,"lon":121.48126,"t":1794376955000,"rx":1794376962156},{"id":"EAL-0098","dir":0,"lat":25.016557,"lon":121.456688,"t":1794376954000,"rx":1794376962156},{"id":"EAL-0236","dir":0,"lat":25.04655,"lon":121.509727,"t":1794376941000,"rx":1794376962156},{"id":"EAL-1157","dir":0,"lat":25.010152,"lon":121.465313,"t":1794376947000,"rx":1794376962156},{"id":"EAL-1161","dir":0,"lat":25.051458,"lon":121.525207,"t":1794376946000,"rx":1794376962156},{"id":"EAL-1162","dir":0,"lat":25.041505,"lon":121.508167,"t":1794376944000,"rx":1794376962156},{"id":"EAL-1168","dir":0,"lat":25.047443,"lon":121.512303,"t":1794376940000,"rx":1794376962156},{"id":"EAL-2266","dir":0,"lat":25.0497,"lon":121.524743,"t":1794376936000,"rx":1794376962156},{"id":"EAL-2267","dir":0,"lat":25.046525,"lon":121.509652,"t":1794376942000,"rx":1794376962156},{"id":"EAA-151","dir":0,"lat":25.01106,"lon":121.456903,"t":1794376972000,"rx":1794376986474},{"id":"EAL-0072","dir":0,"lat":25.024987,"lon":121.500477,"t":1794376972000,"rx":1794376986474},{"id":"EAL-0080","dir":0,"lat":25.014028,"lon":121.4613,"t":1794376963000,"rx":1794376986474},{"id":"EAL-0081","dir":0,"lat":24.996747,"lon":121.492488,"t":1794376975000,"rx":1794376986474},{"id":"EAL-0097","dir":0,"lat":24.999562,"lon":121.481348,"t":1794376975000,"rx":1794376986474},{"id":"EAL-0098","dir":0,"lat":25.015748,"lon":121.456437,"t":1794376971000,"rx":1794376986474},{"id":"EAL-0236","dir":0,"lat":25.047248,"lon":121.510092,"t":1794376977000,"rx":1794376986474},{"id":"EAL-1157","dir":0,"lat":25.009258,"lon":121.465072,"t":1794376967000,"rx":1794376986474},{"id":"EAL-1161","dir":0,"lat":25.05176,"lon":121.525235,"t":1794376966000,"rx":1794376986474},{"id":"EAL-1162","dir":0,"lat":25.042122,"lon":121.508332,"t":1794376967000,"rx":1794376986474},{"id":"EAL-1168","dir":0,"lat":25.047443,"lon":121.5123,"t":1794376960000,"rx":1794376986474},{"id":"EAL-2266","dir":0,"lat":25.050738,"lon":121.525025,"t":1794376976000,"rx":1794376986474},{"id":"EAL-2267","dir":0,"lat":25.047168,"lon":121.510048,"t":1794376975000,"rx":1794376986474},{"id":"EAA-151","dir":0,"lat":25.01035,"lon":121.457828,"t":1794376992000,"rx":1794377009811},{"id":"EAL-0072","dir":0,"lat":25.024988,"lon":121.500477,"t":1794376992000,"rx":1794377009811},{"id":"EAL-0080","dir":0,"lat":25.014015,"lon":121.461297,"t":1794376983000,"rx":1794377009811},{"id":"EAL-0081","dir":0,"lat":24.996767,"lon":121.492605,"t":1794376995000,"rx":1794377009811},{"id":"EAL-0097","dir":0,"lat":24.999413,"lon":121.481272,"t":1794376995000,"rx":1794377009811},{"id":"EAL-0098","dir":0,"lat":25.015185,"lon":121.456338,"t":1794376989000,"rx":1794377009811},{"id":"EAL-0236","dir":0,"lat":25.047735,"lon":121.510238,"t":1794376997000,"rx":1794377009811},{"id":"EAL-1157","dir":0,"lat":25.009225,"lon":121.46503,"t":1794376987000,"rx":1794377009811},{"id":"EAL-1161","dir":0,"lat":25.052158,"lon":121.525362,"t":1794376986000,"rx":1794377009811},{"id":"EAL-1162","dir":0,"lat":25.044885,"lon":121.509107,"t":1794376994000,"rx":1794377009811},{"id":"EAL-1168","dir":0,"lat":25.04741,"lon":121.51231,"t":1794376980000,"rx":1794377009811},{"id":"EAL-2266","dir":0,"lat":25.051387,"lon":121.525213,"t":1794376996000,"rx":1794377009811},{"id":"EAL-2267","dir":0,"lat":25.047618,"lon":121.510218,"t":1794376995000,"rx":1794377009811},{"id":"EAL-0080","dir":0,"lat":25.014007,"lon":121.461308,"t":1794377003000,"rx":1794377021105},{"id":"EAL-0081","dir":0,"lat":24.997068,"lon":121.49335,"t":1794377004000,"rx":1794377021105},{"id":"EAL-1162","dir":0,"lat":25.045003,"lon":121.509205,"t":1794377004000,"rx":1794377021105},{"id":"EAL-1168","dir":0,"lat":25.047253,"lon":121.512707,"t":1794377000000,"rx":1794377021105},{"id":"EAA-151","dir":0,"lat":25.009977,"lon":121.457745,"t":1794377012000,"rx":1794377034840},{"id":"EAL-0072","dir":0,"lat":25.024992,"lon":121.500465,"t":1794377012000,"rx":1794377034840},{"id":"EAL-0080","dir":0,"lat":25.014277,"lon":121.461622,"t":1794377023000,"rx":1794377034840},{"id":"EAL-0081","dir":0,"lat":24.997777,"lon":121.494337,"t":1794377015000,"rx":1794377034840},{"id":"EAL-0097","dir":0,"lat":24.998252,"lon":121.48132,"t":1794377015000,"rx":1794377034840},{"id":"EAL-0098","dir":0,"lat":25.01501,"lon":121.456388,"t":1794377009000,"rx":1794377034840},{"id":"EAL-0236","dir":0,"lat":25.047742,"lon":121.510238,"t":1794377017000,"rx":1794377034840},{"id":"EAL-1157","dir":0,"lat":25.009217,"lon":121.465052,"t":1794377007000,"rx":1794377034840},{"id":"EAL-1161","dir":0,"lat":25.052163,"lon":121.526882,"t":1794377006000,"rx":1794377034840},{"id":"EAL-1162","dir":0,"lat":25.045025,"lon":121.509198,"t":1794377024000,"rx":1794377034840},{"id":"EAL-1168","dir":0,"lat":25.046997,"lon":121.51409,"t":1794377020000,"rx":1794377034840},{"id":"EAL-2266","dir":0,"lat":25.051802,"lon":121.525337,"t":1794377016000,"rx":1794377034840},{"id":"EAL-2267","dir":0,"lat":25.047612,"lon":121.510218,"t":1794377015000,"rx":1794377034840},{"id":"EAL-0098","dir":0,"lat":25.014902,"lon":121.456487,"t":1794377029000,"rx":1794377045519},{"id":"EAL-1157","dir":0,"lat":25.009217,"lon":121.465043,"t":1794377027000,"rx":1794377045519},{"id":"EAL-1161","dir":0,"lat":25.052093,"lon":121.528932,"t":1794377026000,"rx":1794377045519},{"id":"EAA-151","dir":0,"lat":25.009608,"lon":121.45773,"t":1794377043000,"rx":1794377058626},{"id":"EAL-0072","dir":0,"lat":25.025108,"lon":121.500445,"t":1794377040000,"rx":1794377058626},{"id":"EAL-0080","dir":0,"lat":25.013838,"lon":121.46195,"t":1794377043000,"rx":1794377058626},{"id":"EAL-0081","dir":0,"lat":24.998415,"lon":121.495237,"t":1794377035000,"rx":1794377058626},{"id":"EAL-0097","dir":0,"lat":24.997857,"lon":121.48131,"t":1794377035000,"rx":1794377058626},{"id":"EAL-0098","dir":0,"lat":25.013512,"lon":121.45619,"t":1794377049000,"rx":1794377058626},{"id":"EAL-0236","dir":0,"lat":25.047765,"lon":121.510237,"t":1794377037000,"rx":1794377058626},{"id":"EAL-1157","dir":0,"lat":25.009217,"lon":121.465043,"t":1794377047000,"rx":1794377058626},{"id":"EAL-1161","dir":0,"lat":25.052177,"lon":121.529535,"t":1794377046000,"rx":1794377058626},{"id":"EAL-1162","dir":0,"lat":25.045035,"lon":121.50922,"t":1794377044000,"rx":1794377058626},{"id":"EAL-1168","dir":0,"lat":25.046593,"lon":121.515672,"t":1794377040000,"rx":1794377058626},{"id":"EAL-2266","dir":0,"lat":25.051797,"lon":121.525345,"t":1794377036000,"rx":1794377058626},{"id":"EAL-2267","dir":0,"lat":25.047608,"lon":121.510207,"t":1794377035000,"rx":1794377058626},{"id":"EAA-151","dir":0,"lat":25.009133,"lon":121.458438,"t":1794377072000,"rx":1794377082794},{"id":"EAL-0072","dir":0,"lat":25.02746,"lon":121.500542,"t":1794377072000,"rx":1794377082794},{"id":"EAL-0080","dir":0,"lat":25.01368,"lon":121.462198,"t":1794377063000,"rx":1794377082794},{"id":"EAL-0081","dir":0,"lat":24.999707,"lon":121.496258,"t":1794377075000,"rx":1794377082794},{"id":"EAL-0097","dir":0,"lat":24.997445,"lon":121.48129,"t":1794377075000,"rx":1794377082794},{"id":"EAL-0098","dir":0,"lat":25.012775,"lon":121.456167,"t":1794377069000,"rx":1794377082794},{"id":"EAL-0236","dir":0,"lat":25.047778,"lon":121.510238,"t":1794377057000,"rx":1794377082794},{"id":"EAL-1157","dir":0,"lat":25.008922,"lon":121.465087,"t":1794377067000,"rx":1794377082794},{"id":"EAL-1161","dir":0,"lat":25.052105,"lon":121.531358,"t":1794377072000,"rx":1794377082794},{"id":"EAL-1162","dir":0,"lat":25.045053,"lon":121.50922,"t":1794377064000,"rx":1794377082794},{"id":"EAL-1168","dir":0,"lat":25.046592,"lon":121.515688,"t":1794377060000,"rx":1794377082794},{"id":"EAL-2266","dir":0,"lat":25.051797,"lon":121.525347,"t":1794377056000,"rx":1794377082794},{"id":"EAL-2267","dir":0,"lat":25.047687,"lon":121.510162,"t":1794377055000,"rx":1794377082794},{"id":"EAA-151","dir":0,"lat":25.009122,"lon":121.458498,"t":1794377092000,"rx":1794377106781},{"id":"EAL-0072","dir":0,"lat":25.028695,"lon":121.500563,"t":1794377092000,"rx":1794377106781},{"id":"EAL-0080","dir":0,"lat":25.013167,"lon":121.462788,"t":1794377083000,"rx":1794377106781},{"id":"EAL-0081","dir":0,"lat":24.999723,"lon":121.496297,"t":1794377095000,"rx":1794377106781},{"id":"EAL-0097","dir":0,"lat":24.996363,"lon":121.481148,"t":1794377095000,"rx":1794377106781},{"id":"EAL-0098","dir":0,"lat":25.012392,"lon":121.456312,"t":1794377089000,"rx":1794377106781},{"id":"EAL-0236","dir":0,"lat":25.047475,"lon":121.512233,"t":1794377097000,"rx":1794377106781},{"id":"EAL-1157","dir":0,"lat":25.008322,"lon":121.46601,"t":1794377087000,"rx":1794377106781},{"id":"EAL-1161","dir":0,"lat":25.052158,"lon":121.53241,"t":1794377086000,"rx":1794377106781},{"id":"EAL-1162","dir":0,"lat":25.045048,"lon":121.509213,"t":1794377084000,"rx":1794377106781},{"id":"EAL-1168","dir":0,"lat":25.046343,"lon":121.516942,"t":1794377080000,"rx":1794377106781},{"id":"EAL-2266","dir":0,"lat":25.051802,"lon":121.525285,"t":1794377096000,"rx":1794377106781},{"id":"EAL-2267","dir":0,"lat":25.048025,"lon":121.511177,"t":1794377095000,"rx":1794377106781},{"id":"EAA-151","dir":0,"lat":25.008812,"lon":121.459027,"t":1794377112000,"rx":1794377130763},{"id":"EAL-0072","dir":0,"lat":25.02873,"lon":121.500552,"t":1794377112000,"rx":1794377130763},{"id":"EAL-0080","dir":0,"lat":25.012973,"lon":121.463012,"t":1794377103000,"rx":1794377130763},{"id":"EAL-0081","dir":0,"lat":25.0003,"lon":121.496683,"t":1794377115000,"rx":1794377130763},{"id":"EAL-0097","dir":0,"lat":24.995983,"lon":121.481117,"t":1794377115000,"rx":1794377130763},{"id":"EAL-0098","dir":0,"lat":25.012038,"lon":121.455973,"t":1794377109000,"rx":1794377130763},{"id":"EAL-0236","dir":0,"lat":25.047327,"lon":121.512838,"t":1794377117000,"rx":1794377130763},{"id":"EAL-1157","dir":0,"lat":25.00819,"lon":121.466823,"t":1794377118000,"rx":1794377130763},{"id":"EAL-1161","dir":0,"lat":25.05215,"lon":121.532713,"t":1794377106000,"rx":1794377130763},{"id":"EAL-1162","dir":0,"lat":25.045873,"lon":121.509453,"t":1794377104000,"rx":1794377130763},{"id":"EAL-1168","dir":0,"lat":25.045882,"lon":121.518845,"t":1794377100000,"rx":1794377130763},{"id":"EAL-2266","dir":0,"lat":25.051863,"lon":121.525332,"t":1794377116000,"rx":1794377130763},{"id":"EAL-2267","dir":0,"lat":25.047358,"lon":121.512627,"t":1794377115000,"rx":1794377130763},{"id":"EAL-0080","dir":0,"lat":25.01308,"lon":121.462457,"t":1794377123000,"rx":1794377140601},{"id":"EAL-1157","dir":0,"lat":25.007912,"lon":121.467515,"t":1794377124000,"rx":1794377140601},{"id":"EAL-1162","dir":0,"lat":25.046588,"lon":121.509703,"t":1794377124000,"rx":1794377140601},{"id":"EAL-1168","dir":0,"lat":25.04561,"lon":121.519115,"t":1794377120000,"rx":1794377140601},{"id":"EAA-151","dir":0,"lat":25.008815,"lon":121.459057,"t":1794377132000,"rx":1794377153897},{"id":"EAL-0072","dir":0,"lat":25.029177,"lon":121.500545,"t":1794377132000,"rx":1794377153897},{"id":"EAL-0081","dir":0,"lat":25.000455,"lon":121.496788,"t":1794377135000,"rx":1794377153897},{"id":"EAL-0097","dir":0,"lat":24.995695,"lon":121.481105,"t":1794377135000,"rx":1794377153897},{"id":"EAL-0098","dir":0,"lat":25.011538,"lon":121.45512,"t":1794377129000,"rx":1794377153897},{"id":"EAL-0236","dir":0,"lat":25.047335,"lon":121.512858,"t":1794377137000,"rx":1794377153897},{"id":"EAL-1157","dir":0,"lat":25.007368,"lon":121.468962,"t":1794377139000,"rx":1794377153897},{"id":"EAL-1161","dir":0,"lat":25.052072,"lon":121.53541,"t":1794377128000,"rx":1794377153897},{"id":"EAL-2266","dir":0,"lat":25.051932,"lon":121.525295,"t":1794377136000,"rx":1794377153897},{"id":"EAL-2267","dir":0,"lat":25.047365,"lon":121.512707,"t":1794377135000,"rx":1794377153897},{"id":"EAA-151","dir":0,"lat":25.008837,"lon":121.459013,"t":1794377152000,"rx":1794377178598},{"id":"EAL-0072","dir":0,"lat":25.02921,"lon":121.500522,"t":1794377152000,"rx":1794377178598},{"id":"EAL-0080","dir":0,"lat":25.013413,"lon":121.462083,"t":1794377163000,"rx":1794377178598},{"id":"EAL-0081","dir":0,"lat":25.000483,"lon":121.496725,"t":1794377155000,"rx":1794377178598},{"id":"EAL-0097","dir":0,"lat":24.99517,"lon":121.480975,"t":1794377155000,"rx":1794377178598},{"id":"EAL-0098","dir":0,"lat":25.011485,"lon":121.456455,"t":1794377169000,"rx":1794377178598},{"id":"EAL-0236","dir":0,"lat":25.047293,"lon":121.512847,"t":1794377157000,"rx":1794377178598},{"id":"EAL-1157","dir":0,"lat":25.006857,"lon":121.470438,"t":1794377167000,"rx":1794377178598},{"id":"EAL-1161","dir":0,"lat":25.052135,"lon":121.536028,"t":1794377166000,"rx":1794377178598},{"id":"EAL-1162","dir":0,"lat":25.047477,"lon":121.510055,"t":1794377164000,"rx":1794377178598},{"id":"EAL-1168","dir":0,"lat":25.04555,"lon":121.519142,"t":1794377160000,"rx":1794377178598},{"id":"EAL-2266","dir":0,"lat":25.052183,"lon":121.525443,"t":1794377156000,"rx":1794377178598},{"id":"EAL-2267","dir":0,"lat":25.047365,"lon":121.512707,"t":1794377155000,"rx":1794377178598},{"id":"EAA-151","dir":0,"lat":25.008828,"lon":121.459043,"t":1794377192000,"rx":1794377202737},{"id":"EAL-0072","dir":0,"lat":25.030062,"lon":121.500518,"t":1794377192000,"rx":1794377202737},{"id":"EAL-0080","dir":0,"lat":25.013575,"lon":121.462248,"t":1794377183000,"rx":1794377202737},{"id":"EAL-0081","dir":0,"lat":25.000468,"lon":121.496792,"t":1794377195000,"rx":1794377202737},{"id":"EAL-0097","dir":0,"lat":24.995685,"lon":121.482907,"t":1794377195000,"rx":1794377202737},{"id":"EAL-0098","dir":0,"lat":25.011077,"lon":121.45696,"t":1794377189000,"rx":1794377202737},{"id":"EAL-0236","dir":0,"lat":25.046822,"lon":121.513185,"t":1794377177000,"rx":1794377202737},{"id":"EAL-1157","dir":0,"lat":25.006763,"lon":121.470787,"t":1794377187000,"rx":1794377202737},{"id":"EAL-1161","dir":0,"lat":25.052123,"lon":121.53606,"t":1794377186000,"rx":1794377202737},{"id":"EAL-1162","dir":0,"lat":25.047835,"lon":121.510178,"t":1794377184000,"rx":1794377202737},{"id":"EAL-1168","dir":0,"lat":25.045553,"lon":121.51914,"t":1794377180000,"rx":1794377202737},{"id":"EAL-2266","dir":0,"lat":25.0522,"lon":121.525435,"t":1794377176000,"rx":1794377202737},{"id":"EAL-2267","dir":0,"lat":25.047322,"lon":121.512697,"t":1794377175000,"rx":1794377202737},{"id":"EAA-151","dir":0,"lat":25.008827,"lon":121.459047,"t":1794377212000,"rx":1794377226549},{"id":"EAL-0072","dir":0,"lat":25.030912,"lon":121.500572,"t":1794377212000,"rx":1794377226549},{"id":"EAL-0080","dir":0,"lat":25.013577,"lon":121.462577,"t":1794377203000,"rx":1794377226549},{"id":"EAL-0081","dir":0,"lat":25.00101,"lon":121.497087,"t":1794377215000,"rx":1794377226549},{"id":"EAL-0097","dir":0,"lat":24.99569,"lon":121.482952,"t":1794377215000,"rx":1794377226549},{"id":"EAL-0098","dir":0,"lat":25.010553,"lon":121.457693,"t":1794377214000,"rx":1794377226549},{"id":"EAL-0236","dir":0,"lat":25.046637,"lon":121.514732,"t":1794377217000,"rx":1794377226549},{"id":"EAL-1157","dir":0,"lat":25.006763,"lon":121.470783,"t":1794377207000,"rx":1794377226549},{"id":"EAL-1161","dir":0,"lat":25.052113,"lon":121.536057,"t":1794377206000,"rx":1794377226549},{"id":"EAL-1162","dir":0,"lat":25.048052,"lon":121.511307,"t":1794377204000,"rx":1794377226549},{"id":"EAL-1168","dir":0,"lat":25.045382,"lon":121.520728,"t":1794377219000,"rx":1794377226549},{"id":"EAL-2266","dir":0,"lat":25.052073,"lon":121.527795,"t":1794377216000,"rx":1794377226549},{"id":"EAL-2267","dir":0,"lat":25.046932,"lon":121.514193,"t":1794377215000,"rx":1794377226549},{"id":"EAA-151","dir":0,"lat":25.008842,"lon":121.459655,"t":1794377238000,"rx":1794377250371},{"id":"EAL-0072","dir":0,"lat":25.031223,"lon":121.50137,"t":1794377237000,"rx":1794377250371},{"id":"EAL-0080","dir":0,"lat":25.013292,"lon":121.462843,"t":1794377223000,"rx":1794377250371},{"id":"EAL-0081","dir":0,"lat":25.001852,"lon":121.496872,"t":1794377235000,"rx":1794377250371},{"id":"EAL-0097","dir":0,"lat":24.995663,"lon":121.482968,"t":1794377235000,"rx":1794377250371},{"id":"EAL-0098","dir":0,"lat":25.009632,"lon":121.45778,"t":1794377233000,"rx":1794377250371},{"id":"EAL-0236","dir":0,"lat":25.046455,"lon":121.515827,"t":1794377237000,"rx":1794377250371},{"id":"EAL-1157","dir":0,"lat":25.006763,"lon":121.470783,"t":1794377227000,"rx":1794377250371},{"id":"EAL-1159","dir":0,"lat":25.023797,"lon":121.44992,"t":1794377231000,"rx":1794377250371},{"id":"EAL-1161","dir":0,"lat":25.051948,"lon":121.537202,"t":1794377231000,"rx":1794377250371},{"id":"EAL-1162","dir":0,"lat":25.04715,"lon":121.513592,"t":1794377231000,"rx":1794377250371},{"id":"EAL-1168","dir":0,"lat":25.045493,"lon":121.520392,"t":1794377220000,"rx":1794377250371},{"id":"EAL-2266","dir":0,"lat":25.0521,"lon":121.529478,"t":1794377236000,"rx":1794377250371},{"id":"EAL-2267","dir":0,"lat":25.04653,"lon":121.515597,"t":1794377235000,"rx":1794377250371},{"id":"EAL-0080","dir":0,"lat":25.012923,"lon":121.46355,"t":1794377245000,"rx":1794377261145},{"id":"EAL-1162","dir":0,"lat":25.046885,"lon":121.514622,"t":1794377244000,"rx":1794377261145},{"id":"EAL-1168","dir":0,"lat":25.04525,"lon":121.521193,"t":1794377240000,"rx":1794377261145},{"id":"EAA-151","dir":0,"lat":25.009617,"lon":121.460288,"t":1794377252000,"rx":1794377274392},{"id":"EAL-0072","dir":0,"lat":25.031223,"lon":121.502082,"t":1794377252000,"rx":1794377274392},{"id":"EAL-0080","dir":0,"lat":25.012008,"lon":121.464368,"t":1794377263000,"rx":1794377274392},{"id":"EAL-0081","dir":0,"lat":25.002198,"lon":121.496793,"t":1794377259000,"rx":1794377274392},{"id":"EAL-0097","dir":0,"lat":24.995655,"lon":121.48298,"t":1794377255000,"rx":1794377274392},{"id":"EAL-0098","dir":0,"lat":25.009347,"lon":121.457993,"t":1794377249000,"rx":1794377274392},{"id":"EAL-0236","dir":0,"lat":25.046665,"lon":121.515803,"t":1794377257000,"rx":1794377274392},{"id":"EAL-1157","dir":0,"lat":25.006608,"lon":121.47138,"t":1794377262000,"rx":1794377274392},{"id":"EAL-1159","dir":0,"lat":25.023733,"lon":121.449932,"t":1794377251000,"rx":1794377274392},{"id":"EAL-1161","dir":0,"lat":25.051922,"lon":121.53909,"t":1794377250000,"rx":1794377274392},{"id":"EAL-1162","dir":0,"lat":25.046715,"lon":121.515492,"t":1794377264000,"rx":1794377274392},{"id":"EAL-1168","dir":0,"lat":25.045177,"lon":121.521442,"t":1794377260000,"rx":1794377274392},{"id":"EAL-2266","dir":0,"lat":25.052083,"lon":121.52994,"t":1794377256000,"rx":1794377274392},{"id":"EAL-2267","dir":0,"lat":25.046523,"lon":121.515713,"t":1794377255000,"rx":1794377274392},{"id":"EAA-151","dir":0,"lat":25.01074,"lon":121.461242,"t":1794377272000,"rx":1794377296670},{"id":"EAL-0072","dir":0,"lat":25.03122,"lon":121.502237,"t":1794377272000,"rx":1794377296670},{"id":"EAL-0080","dir":0,"lat":25.011838,"lon":121.464503,"t":1794377283000,"rx":1794377296670},{"id":"EAL-0081","dir":0,"lat":25.00307,"lon":121.496697,"t":1794377275000,"rx":1794377296670},{"id":"EAL-0097","dir":0,"lat":24.99626,"lon":121.484788,"t":1794377285000,"rx":1794377296670},{"id":"EAL-0098","dir":0,"lat":25.008942,"lon":121.458855,"t":1794377289000,"rx":1794377296670},{"id":"EAL-0236","dir":0,"lat":25.046048,"lon":121.517443,"t":1794377277000,"rx":1794377296670},{"id":"EAL-1157","dir":0,"lat":25.006388,"lon":121.472953,"t":1794377287000,"rx":1794377296670},{"id":"EAL-1159","dir":0,"lat":25.024333,"lon":121.450128,"t":1794377271000,"rx":1794377296670},{"id":"EAL-1161","dir":0,"lat":25.051888,"lon":121.539772,"t":1794377286000,"rx":1794377296670},{"id":"EAL-1162","dir":0,"lat":25.046563,"lon":121.516262,"t":1794377284000,"rx":1794377296670},{"id":"EAL-1168","dir":0,"lat":25.04493,"lon":121.521417,"t":1794377280000,"rx":1794377296670},{"id":"EAL-2266","dir":0,"lat":25.05217,"lon":121.531352,"t":1794377284000,"rx":1794377296670},{"id":"EAL-2267","dir":0,"lat":25.046357,"lon":121.516352,"t":1794377275000,"rx":1794377296670},{"id":"EAA-151","dir":0,"lat":25.011227,"lon":121.461623,"t":1794377292000,"rx":1794377320722},{"id":"EAL-0072","dir":0,"lat":25.031308,"lon":121.503975,"t":1794377304000,"rx":1794377320722},{"id":"EAL-0080","dir":0,"lat":25.011747,"lon":121.464583,"t":1794377303000,"rx":1794377320722},{"id":"EAL-0081","dir":0,"lat":25.003213,"lon":121.496703,"t":1794377295000,"rx":1794377320722},{"id":"EAL-0097","dir":0,"lat":24.996253,"lon":121.485117,"t":1794377295000,"rx":1794377320722},{"id":"EAL-0098","dir":0,"lat":25.008903,"lon":121.458953,"t":1794377309000,"rx":1794377320722},{"id":"EAL-0236","dir":0,"lat":25.045538,"lon":121.518895,"t":1794377297000,"rx":1794377320722},{"id":"EAL-1157","dir":0,"lat":25.006407,"lon":121.473172,"t":1794377307000,"rx":1794377320722},{"id":"EAL-1159","dir":0,"lat":25.025188,"lon":121.450917,"t":1794377296000,"rx":1794377320722},{"id":"EAL-1161","dir":0,"lat":25.051888,"lon":121.54222,"t":1794377306000,"rx":1794377320722},{"id":"EAL-1162","dir":0,"lat":25.046138,"lon":121.51776,"t":1794377304000,"rx":1794377320722},{"id":"EAL-1168","dir":0,"lat":25.044608,"lon":121.5214,"t":1794377300000,"rx":1794377320722},{"id":"EAL-2266","dir":0,"lat":25.052012,"lon":121.532825,"t":1794377301000,"rx":1794377320722},{"id":"EAL-2267","dir":0,"lat":25.045885,"lon":121.51831,"t":1794377296000,"rx":1794377320722},{"id":"EAA-151","dir":0,"lat":25.011242,"lon":121.4617,"t":1794377312000,"rx":1794377332836},{"id":"EAL-0072","dir":0,"lat":25.031425,"lon":121.50429,"t":1794377312000,"rx":1794377332836},{"id":"EAL-0081","dir":0,"lat":25.004055,"lon":121.496528,"t":1794377315000,"rx":1794377332836},{"id":"EAL-0097","dir":0,"lat":24.996328,"lon":121.486482,"t":1794377315000,"rx":1794377332836},{"id":"EAL-1159","dir":0,"lat":25.026482,"lon":121.452337,"t":1794377314000,"rx":1794377332836},{"id":"EAA-151","dir":0,"lat":25.01134,"lon":121.461823,"t":1794377332000,"rx":1794377344757},{"id":"EAL-0072","dir":0,"lat":25.032222,"lon":121.504662,"t":1794377332000,"rx":1794377344757},{"id":"EAL-0080","dir":0,"lat":25.011707,"lon":121.464585,"t":1794377323000,"rx":1794377344757},{"id":"EAL-0081","dir":0,"lat":25.004587,"lon":121.496333,"t":1794377335000,"rx":1794377344757},{"id":"EAL-0097","dir":0,"lat":24.99638,"lon":121.488632,"t":1794377335000,"rx":1794377344757},{"id":"EAL-0098","dir":0,"lat":25.008838,"lon":121.458847,"t":1794377329000,"rx":1794377344757},{"id":"EAL-0236","dir":0,"lat":25.045618,"lon":121.519118,"t":1794377317000,"rx":1794377344757},{"id":"EAL-1157","dir":0,"lat":25.006283,"lon":121.473552,"t":1794377327000,"rx":1794377344757},{"id":"EAL-1159","dir":0,"lat":25.026732,"lon":121.452795,"t":1794377331000,"rx":1794377344757},{"id":"EAL-1161","dir":0,"lat":25.051845,"lon":121.54331,"t":1794377326000,"rx":1794377344757},{"id":"EAL-1162","dir":0,"lat":25.045837,"lon":121.518527,"t":1794377324000,"rx":1794377344757},{"id":"EAL-1168","dir":0,"lat":25.04451,"lon":121.521457,"t":1794377320000,"rx":1794377344757},{"id":"EAL-2266","dir":0,"lat":25.052018,"lon":121.532888,"t":1794377317000,"rx":1794377344757},{"id":"EAL-2267","dir":0,"lat":25.045795,"lon":121.518838,"t":1794377315000,"rx":1794377344757},{"id":"EAL-0080","dir":0,"lat":25.011565,"lon":121.464713,"t":1794377343000,"rx":1794377359605},{"id":"EAL-0097","dir":0,"lat":24.996352,"lon":121.489297,"t":1794377342000,"rx":1794377359605},{"id":"EAL-0098","dir":0,"lat":25.008908,"lon":121.458858,"t":1794377349000,"rx":1794377359605},{"id":"EAL-0236","dir":0,"lat":25.045757,"lon":121.51944,"t":1794377337000,"rx":1794377359605},{"id":"EAL-1157","dir":0,"lat":25.006263,"lon":121.473545,"t":1794377347000,"rx":1794377359605},{"id":"EAL-1159","dir":0,"lat":25.026042,"lon":121.453728,"t":1794377347000,"rx":1794377359605},{"id":"EAL-1161","dir":0,"lat":25.051893,"lon":121.543368,"t":1794377346000,"rx":1794377359605},{"id":"EAL-1162","dir":0,"lat":25.045947,"lon":121.518598,"t":1794377344000,"rx":1794377359605},{"id":"EAL-1168","dir":0,"lat":25.04425,"lon":121.521362,"t":1794377341000,"rx":1794377359605},{"id":"EAL-2266","dir":0,"lat":25.052095,"lon":121.532792,"t":1794377336000,"rx":1794377359605},{"id":"EAL-2267","dir":0,"lat":25.045803,"lon":121.518848,"t":1794377335000,"rx":1794377359605},{"id":"EAA-151","dir":0,"lat":25.012632,"lon":121.460488,"t":1794377373000,"rx":1794377380931},{"id":"EAL-0072","dir":0,"lat":25.034263,"lon":121.505793,"t":1794377372000,"rx":1794377380931},{"id":"EAL-0080","dir":0,"lat":25.01157,"lon":121.464713,"t":1794377363000,"rx":1794377380931},{"id":"EAL-0081","dir":0,"lat":25.00839,"lon":121.495427,"t":1794377375000,"rx":1794377380931},{"id":"EAL-0097","dir":0,"lat":24.996325,"lon":121.489682,"t":1794377375000,"rx":1794377380931},{"id":"EAL-0098","dir":0,"lat":25.008902,"lon":121.458873,"t":1794377369000,"rx":1794377380931},{"id":"EAL-0236","dir":0,"lat":25.045703,"lon":121.519108,"t":1794377357000,"rx":1794377380931},{"id":"EAL-1157","dir":0,"lat":25.006275,"lon":121.473533,"t":1794377367000,"rx":1794377380931},{"id":"EAL-1159","dir":0,"lat":25.025625,"lon":121.45427,"t":1794377371000,"rx":1794377380931},{"id":"EAL-1161","dir":0,"lat":25.051928,"lon":121.543355,"t":1794377366000,"rx":1794377380931},{"id":"EAL-1162","dir":0,"lat":25.045907,"lon":121.51862,"t":1794377364000,"rx":1794377380931},{"id":"EAL-1168","dir":0,"lat":25.043938,"lon":121.522047,"t":1794377365000,"rx":1794377380931},{"id":"EAL-2266","dir":0,"lat":25.052093,"lon":121.532815,"t":1794377356000,"rx":1794377380931},{"id":"EAL-2267","dir":0,"lat":25.045803,"lon":121.518848,"t":1794377375000,"rx":1794377380931},{"id":"EAA-151","dir":0,"lat":25.012657,"lon":121.460483,"t":1794377392000,"rx":1794377404783},{"id":"EAL-0072","dir":0,"lat":25.035567,"lon":121.506555,"t":1794377392000,"rx":1794377404783},{"id":"EAL-0080","dir":0,"lat":25.010478,"lon":121.465277,"t":1794377391000,"rx":1794377404783},{"id":"EAL-0081","dir":0,"lat":25.010607,"lon":121.495437,"t":1794377395000,"rx":1794377404783},{"id":"EAL-0097","dir":0,"lat":24.99629,"lon":121.489693,"t":1794377395000,"rx":1794377404783},{"id":"EAL-0098","dir":0,"lat":25.008812,"lon":121.459672,"t":1794377392000,"rx":1794377404783},{"id":"EAL-0236","dir":0,"lat":25.04592,"lon":121.519208,"t":1794377377000,"rx":1794377404783},{"id":"EAL-1157","dir":0,"lat":25.00614,"lon":121.474362,"t":1794377387000,"rx":1794377404783},{"id":"EAL-1159","dir":0,"lat":25.024747,"lon":121.454257,"t":1794377392000,"rx":1794377404783},{"id":"EAL-1161","dir":0,"lat":25.05194,"lon":121.543358,"t":1794377386000,"rx":1794377404783},{"id":"EAL-1162","dir":0,"lat":25.045905,"lon":121.518615,"t":1794377384000,"rx":1794377404783},{"id":"EAL-1168","dir":0,"lat":25.043918,"lon":121.522263,"t":1794377380000,"rx":1794377404783},{"id":"EAL-2266","dir":0,"lat":25.052102,"lon":121.532832,"t":1794377376000,"rx":1794377404783}],"boardingEta":[{"t":1794376161163,"eta":-1,"arriving":false},{"t":1794376169519,"eta":-1,"arriving":false},{"t":1794376182180,"eta":186,"arriving":false},{"t":1794376193812,"eta":186,"arriving":false},{"t":1794376208338,"eta":-1,"arriving":false},{"t":1794376217701,"eta":-1,"arriving":false},{"t":1794376232517,"eta":175,"arriving":false},{"t":1794376241661,"eta":175,"arriving":false},{"t":1794376254932,"eta":116,"arriving":false},{"t":1794376265507,"eta":116,"arriving":false},{"t":1794376278192,"eta":115,"arriving":false},{"t":1794376292087,"eta":114,"arriving":false},{"t":1794376301192,"eta":114,"arriving":false},{"t":1794376314034,"eta":103,"arriving":false},{"t":1794376326472,"eta":103,"arriving":false},{"t":1794376338415,"eta":102,"arriving":false},{"t":1794376350524,"eta":102,"arriving":false},{"t":1794376362708,"eta":97,"arriving":false},{"t":1794376374860,"eta":97,"arriving":false},{"t":1794376386614,"eta":20,"arriving":false},{"t":1794376397644,"eta":20,"arriving":false},{"t":1794376412620,"eta":18,"arriving":false},{"t":1794376421370,"eta":18,"arriving":false},{"t":1794376435321,"eta":19,"arriving":false},{"t":1794376445195,"eta":19,"arriving":false},{"t":1794376460328,"eta":-1,"arriving":false},{"t":1794376469440,"eta":-1,"arriving":false},{"t":1794376483762,"eta":580,"arriving":false},{"t":1794376493332,"eta":580,"arriving":false},{"t":1794376508346,"eta":567,"arriving":false},{"t":1794376517400,"eta":567,"arriving":false},{"t":1794376531141,"eta":563,"arriving":false},{"t":1794376541250,"eta":563,"arriving":false},{"t":1794376555485,"eta":566,"arriving":false},{"t":1794376565240,"eta":566,"arriving":false},{"t":1794376580076,"eta":566,"arriving":false},{"t":1794376589613,"eta":566,"arriving":false},{"t":1794376603608,"eta":566,"arriving":false},{"t":1794376613254,"eta":566,"arriving":false},{"t":1794376628378,"eta":458,"arriving":false},{"t":1794376637375,"eta":458,"arriving":false},{"t":1794376652253,"eta":452,"arriving":false},{"t":1794376661383,"eta":452,"arriving":false},{"t":1794376675104,"eta":363,"arriving":false},{"t":1794376685506,"eta":363,"arriving":false},{"t":1794376699548,"eta":366,"arriving":false},{"t":1794376709397,"eta":366,"arriving":false},{"t":1794376723203,"eta":366,"arriving":false},{"t":1794376733347,"eta":366,"arriving":false},{"t":1794376745964,"eta":291,"arriving":false},{"t":1794376759202,"eta":291,"arriving":false},{"t":1794376770255,"eta":294,"arriving":false},{"t":1794376785008,"eta":293,"arriving":false},{"t":1794376793262,"eta":293,"arriving":false},{"t":1794376807605,"eta":237,"arriving":false},{"t":1794376817441,"eta":237,"arriving":false},{"t":1794376831615,"eta":138,"arriving":false},{"t":1794376841689,"eta":138,"arriving":false},{"t":1794376854925,"eta":141,"arriving":false},{"t":1794376866466,"eta":138,"arriving":false},{"t":1794376878846,"eta":138,"arriving":false},{"t":1794376890367,"eta":138,"arriving":false},{"t":1794376902417,"eta":138,"arriving":false},{"t":1794376914080,"eta":47,"arriving":false},{"t":1794376926656,"eta":47,"arriving":false},{"t":1794376938489,"eta":-1,"arriving":false},{"t":1794376949704,"eta":-1,"arriving":false},{"t":1794376963702,"eta":832,"arriving":false},{"t":1794376973445,"eta":832,"arriving":false},{"t":1794376988181,"eta":730,"arriving":false},{"t":1794376997395,"eta":730,"arriving":false},{"t":1794377011382,"eta":725,"arriving":false},{"t":1794377021713,"eta":725,"arriving":false},{"t":1794377036381,"eta":648,"arriving":false},{"t":1794377046142,"eta":648,"arriving":false},{"t":1794377060522,"eta":652,"arriving":false},{"t":1794377069762,"eta":652,"arriving":false},{"t":1794377084355,"eta":595,"arriving":false},{"t":1794377093783,"eta":595,"arriving":false},{"t":1794377108774,"eta":594,"arriving":false},{"t":1794377117786,"eta":594,"arriving":false},{"t":1794377132890,"eta":592,"arriving":false},{"t":1794377141239,"eta":592,"arriving":false},{"t":1794377154508,"eta":593,"arriving":false},{"t":1794377165329,"eta":593,"arriving":false},{"t":1794377180180,"eta":513,"arriving":false},{"t":1794377189844,"eta":513,"arriving":false},{"t":1794377204253,"eta":516,"arriving":false},{"t":1794377213529,"eta":516,"arriving":false},{"t":1794377228410,"eta":461,"arriving":false},{"t":1794377237765,"eta":461,"arriving":false},{"t":1794377251874,"eta":461,"arriving":false},{"t":1794377261751,"eta":461,"arriving":false},{"t":1794377276519,"eta":464,"arriving":false},{"t":1794377285907,"eta":464,"arriving":false},{"t":1794377298048,"eta":263,"arriving":false},{"t":1794377312042,"eta":263,"arriving":false},{"t":1794377322545,"eta":262,"arriving":false},{"t":1794377333740,"eta":262,"arriving":false},{"t":1794377346549,"eta":265,"arriving":false},{"t":1794377361626,"eta":263,"arriving":false},{"t":1794377369592,"eta":263,"arriving":false},{"t":1794377382264,"eta":265,"arriving":false},{"t":1794377394139,"eta":265,"arriving":false},{"t":1794377406609,"eta":149,"arriving":false}],"riderFixes":[{"t":1794376160000,"lat":25.023426,"lon":121.499905,"acc":15.0,"spd":0.0},{"t":1794376165000,"lat":25.023088,"lon":121.500284,"acc":15.0,"spd":0.1},{"t":1794376170000,"lat":25.023256,"lon":121.499874,"acc":15.0,"spd":0.0},{"t":1794376175000,"lat":25.023386,"lon":121.499764,"acc":15.0,"spd":0.0},{"t":1794376180000,"lat":25.023266,"lon":121.500018,"acc":15.0,"spd":0.0},{"t":1794376185000,"lat":25.023484,"lon":121.499792,"acc":15.0,"spd":0.0},{"t":1794376190000,"lat":25.023218,"lon":121.499876,"acc":15.0,"spd":0.3},{"t":1794376195000,"lat":25.023344,"lon":121.499778,"acc":15.0,"spd":0.3},{"t":1794376200000,"lat":25.023083,"lon":121.500139,"acc":15.0,"spd":0.0},{"t":1794376205000,"lat":25.023308,"lon":121.499983,"acc":15.0,"spd":0.1},{"t":1794376210000,"lat":25.023176,"lon":121.499795,"acc":15.0,"spd":0.0},{"t":1794376215000,"lat":25.023155,"lon":121.500206,"acc":15.0,"spd":0.0},{"t":1794376220000,"lat":25.023217,"lon":121.500036,"acc":15.0,"spd":0.0},{"t":1794376225000,"lat":25.023321,"lon":121.500048,"acc":15.0,"spd":0.2},{"t":1794376230000,"lat":25.023238,"lon":121.499869,"acc":15.0,"spd":0.0},{"t":1794376235000,"lat":25.023256,"lon":121.499722,"acc":15.0,"spd":0.0},{"t":1794376240000,"lat":25.023263,"lon":121.499901,"acc":15.0,"spd":0.3},{"t":1794376245000,"lat":25.023195,"lon":121.499979,"acc":15.0,"spd":0.0},{"t":1794376250000,"lat":25.023328,"lon":121.499826,"acc":15.0,"spd":0.0},{"t":1794376255000,"lat":25.023295,"lon":121.499921,"acc":15.0,"spd":0.0},{"t":1794376260000,"lat":25.02332,"lon":121.499895,"acc":15.0,"spd":0.2},{"t":1794376265000,"lat":25.023292,"lon":121.499991,"acc":15.0,"spd":0.1},{"t":1794376270000,"lat":25.023255,"lon":121.49991,"acc":15.0,"spd":0.0},{"t":1794376275000,"lat":25.02315,"lon":121.500088,"acc":15.0,"spd":0.0},{"t":1794376280000,"lat":25.023097,"lon":121.499993,"acc":15.0,"spd":0.0},{"t":1794376285000,"lat":25.023387,"lon":121.499898,"acc":15.0,"spd":0.2},{"t":1794376290000,"lat":25.0231,"lon":121.499858,"acc":15.0,"spd":0.0},{"t":1794376295000,"lat":25.023163,"lon":121.499868,"acc":15.0,"spd":0.0},{"t":1794376300000,"lat":25.023218,"lon":121.499922,"acc":15.0,"spd":0.1},{"t":1794376305000,"lat":25.023266,"lon":121.499632,"acc":15.0,"spd":0.0},{"t":1794376310000,"lat":25.023304,"lon":121.499808,"acc":15.0,"spd":0.0},{"t":1794376315000,"lat":25.023384,"lon":121.499872,"acc":15.0,"spd":0.3},{"t":1794376320000,"lat":25.023428,"lon":121.499905,"acc":15.0,"spd":0.4},{"t":1794376325000,"lat":25.023211,"lon":121.49979,"acc":15.0,"spd":0.0},{"t":1794376330000,"lat":25.023201,"lon":121.500101,"acc":15.0,"spd":0.4},{"t":1794376335000,"lat":25.023357,"lon":121.500039,"acc":15.0,"spd":0.4},{"t":1794376340000,"lat":25.023395,"lon":121.499981,"acc":15.0,"spd":0.7},{"t":1794376345000,"lat":25.023362,"lon":121.499721,"acc":15.0,"spd":0.0},{"t":1794376350000,"lat":25.023373,"lon":121.499734,"acc":15.0,"spd":0.0},{"t":1794376355000,"lat":25.023337,"lon":121.500021,"acc":15.0,"spd":0.0},{"t":1794376360000,"lat":25.023519,"lon":121.499888,"acc":15.0,"spd":0.0},{"t":1794376365000,"lat":25.023454,"lon":121.499915,"acc":15.0,"spd":0.4},{"t":1794376370000,"lat":25.023309,"lon":121.499999,"acc":15.0,"spd":0.0},{"t":1794376375000,"lat":25.023184,"lon":121.499862,"acc":15.0,"spd":0.2},{"t":1794376380000,"lat":25.023153,"lon":121.499802,"acc":15.0,"spd":0.7},{"t":1794376385000,"lat":25.023375,"lon":121.499771,"acc":15.0,"spd":0.1},{"t":1794376390000,"lat":25.023305,"lon":121.49984,"acc":15.0,"spd":0.0},{"t":1794376395000,"lat":25.023276,"lon":121.499917,"acc":15.0,"spd":0.0},{"t":1794376400000,"lat":25.023384,"lon":121.499983,"acc":15.0,"spd":0.0},{"t":1794376405000,"lat":25.023353,"lon":121.499953,"acc":15.0,"spd":0.0},{"t":1794376410000,"lat":25.023196,"lon":121.499837,"acc":15.0,"spd":0.2},{"t":1794376415000,"lat":25.023229,"lon":121.50011,"acc":15.0,"spd":0.2},{"t":1794376420000,"lat":25.023293,"lon":121.499748,"acc":15.0,"spd":0.0},{"t":1794376425000,"lat":25.023361,"lon":121.499807,"acc":15.0,"spd":0.5},{"t":1794376430000,"lat":25.023446,"lon":121.499858,"acc":15.0,"spd":0.3},{"t":1794376435000,"lat":25.023239,"lon":121.499894,"acc":15.0,"spd":0.3},{"t":1794376440000,"lat":25.023277,"lon":121.500311,"acc":15.0,"spd":0.0},{"t":1794376445000,"lat":25.023261,"lon":121.500019,"acc":15.0,"spd":0.0},{"t":1794376450000,"lat":25.023182,"lon":121.499848,"acc":15.0,"spd":0.0},{"t":1794376455000,"lat":25.023285,"lon":121.499853,"acc":15.0,"spd":0.4},{"t":1794376460000,"lat":25.023653,"lon":121.500091,"acc":15.0,"spd":5.6},{"t":1794376465000,"lat":25.02359,"lon":121.500181,"acc":15.0,"spd":7.2},{"t":1794376470000,"lat":25.023963,"lon":121.500032,"acc":15.0,"spd":5.3},{"t":1794376475000,"lat":25.024414,"lon":121.500295,"acc":15.0,"spd":5.6},{"t":1794376480000,"lat":25.024564,"lon":121.500201,"acc":15.0,"spd":5.8},{"t":1794376485000,"lat":25.024678,"lon":121.500541,"acc":15.0,"spd":6.1},{"t":1794376490000,"lat":25.025016,"lon":121.500264,"acc":15.0,"spd":5.0},{"t":1794376495000,"lat":25.025128,"lon":121.500389,"acc":15.0,"spd":5.4},{"t":1794376500000,"lat":25.025522,"lon":121.500614,"acc":15.0,"spd":4.9},{"t":1794376505000,"lat":25.025674,"lon":121.500532,"acc":15.0,"spd":4.3},{"t":1794376510000,"lat":25.025763,"lon":121.500665,"acc":15.0,"spd":2.3},{"t":1794376515000,"lat":25.025993,"lon":121.500844,"acc":15.0,"spd":2.7},{"t":1794376520000,"lat":25.025918,"lon":121.500602,"acc":15.0,"spd":2.4},{"t":1794376525000,"lat":25.025964,"lon":121.500634,"acc":15.0,"spd":2.4},{"t":1794376530000,"lat":25.026039,"lon":121.500591,"acc":15.0,"spd":0.5},{"t":1794376535000,"lat":25.026134,"lon":121.500637,"acc":15.0,"spd":0.0},{"t":1794376540000,"lat":25.026159,"lon":121.50065,"acc":15.0,"spd":0.0},{"t":1794376545000,"lat":25.026078,"lon":121.500435,"acc":15.0,"spd":0.0},{"t":1794376550000,"lat":25.026115,"lon":121.500538,"acc":15.0,"spd":0.1},{"t":1794376555000,"lat":25.026164,"lon":121.500576,"acc":15.0,"spd":0.5},{"t":1794376560000,"lat":25.025963,"lon":121.500575,"acc":15.0,"spd":1.2},{"t":1794376565000,"lat":25.026058,"lon":121.500533,"acc":15.0,"spd":2.0},{"t":1794376570000,"lat":25.026502,"lon":121.500618,"acc":15.0,"spd":6.3},{"t":1794376575000,"lat":25.026833,"lon":121.500525,"acc":15.0,"spd":6.8},{"t":1794376580000,"lat":25.027185,"lon":121.500516,"acc":15.0,"spd":6.9},{"t":1794376585000,"lat":25.027416,"lon":121.500434,"acc":15.0,"spd":7.0},{"t":1794376590000,"lat":25.027856,"lon":121.500727,"acc":15.0,"spd":10.1},{"t":1794376595000,"lat":25.028297,"lon":121.500641,"acc":15.0,"spd":9.7},{"t":1794376600000,"lat":25.028769,"lon":121.500289,"acc":15.0,"spd":9.6},{"t":1794376605000,"lat":25.028991,"lon":121.500568,"acc":15.0,"spd":9.2},{"t":1794376610000,"lat":25.029516,"lon":121.500345,"acc":15.0,"spd":10.0},{"t":1794376615000,"lat":25.03021,"lon":121.500694,"acc":15.0,"spd":9.1},{"t":1794376620000,"lat":25.030455,"lon":121.500625,"acc":15.0,"spd":9.8},{"t":1794376625000,"lat":25.030822,"lon":121.500956,"acc":15.0,"spd":8.2},{"t":1794376630000,"lat":25.03083,"lon":121.500905,"acc":15.0,"spd":5.2},{"t":1794376635000,"lat":25.031015,"lon":121.501232,"acc":15.0,"spd":4.5},{"t":1794376640000,"lat":25.031158,"lon":121.501256,"acc":15.0,"spd":4.9},{"t":1794376645000,"lat":25.031051,"lon":121.501384,"acc":15.0,"spd":5.1},{"t":1794376650000,"lat":25.031172,"lon":121.501459,"acc":15.0,"spd":3.3},{"t":1794376655000,"lat":25.031434,"lon":121.501704,"acc":15.0,"spd":3.6},{"t":1794376660000,"lat":25.031253,"lon":121.501965,"acc":15.0,"spd":4.2},{"t":1794376665000,"lat":25.031338,"lon":121.50212,"acc":15.0,"spd":4.5},{"t":1794376670000,"lat":25.031169,"lon":121.502642,"acc":15.0,"spd":5.6},{"t":1794376675000,"lat":25.0312,"lon":121.502839,"acc":15.0,"spd":6.5},{"t":1794376680000,"lat":25.031234,"lon":121.502933,"acc":15.0,"spd":4.8},{"t":1794376685000,"lat":25.031053,"lon":121.503288,"acc":15.0,"spd":5.0},{"t":1794376690000,"lat":25.031393,"lon":121.503321,"acc":15.0,"spd":4.7},{"t":1794376695000,"lat":25.031287,"lon":121.503708,"acc":15.0,"spd":4.4},{"t":1794376700000,"lat":25.031364,"lon":121.503704,"acc":15.0,"spd":4.9},{"t":1794376705000,"lat":25.031383,"lon":121.50393,"acc":15.0,"spd":4.3},{"t":1794376710000,"lat":25.03165,"lon":121.504063,"acc":15.0,"spd":5.6},{"t":1794376715000,"lat":25.031826,"lon":121.50399,"acc":15.0,"spd":5.8},{"t":1794376720000,"lat":25.03199,"lon":121.504561,"acc":15.0,"spd":5.7},{"t":1794376725000,"lat":25.032056,"lon":121.504685,"acc":15.0,"spd":5.9},{"t":1794376730000,"lat":25.032465,"lon":121.504821,"acc":15.0,"spd":5.2},{"t":1794376735000,"lat":25.032646,"lon":121.504939,"acc":15.0,"spd":5.9},{"t":1794376740000,"lat":25.03301,"lon":121.504962,"acc":15.0,"spd":5.8},{"t":1794376745000,"lat":25.033234,"lon":121.505226,"acc":15.0,"spd":6.1},{"t":1794376750000,"lat":25.033599,"lon":121.505291,"acc":15.0,"spd":5.9},{"t":1794376755000,"lat":25.033629,"lon":121.505319,"acc":15.0,"spd":5.8},{"t":1794376760000,"lat":25.03416,"lon":121.505648,"acc":15.0,"spd":8.1},{"t":1794376765000,"lat":25.034372,"lon":121.505644,"acc":15.0,"spd":8.1},{"t":1794376770000,"lat":25.034528,"lon":121.506014,"acc":15.0,"spd":8.6},{"t":1794376775000,"lat":25.034917,"lon":121.50614,"acc":15.0,"spd":7.7},{"t":1794376780000,"lat":25.035448,"lon":121.506473,"acc":15.0,"spd":7.6},{"t":1794376785000,"lat":25.035596,"lon":121.506366,"acc":15.0,"spd":7.2},{"t":1794376790000,"lat":25.035596,"lon":121.506319,"acc":15.0,"spd":1.4},{"t":1794376795000,"lat":25.035684,"lon":121.506431,"acc":15.0,"spd":0.4},{"t":1794376800000,"lat":25.035728,"lon":121.506567,"acc":15.0,"spd":1.7},{"t":1794376805000,"lat":25.03584,"lon":121.506547,"acc":15.0,"spd":0.9},{"t":1794376810000,"lat":25.035728,"lon":121.506607,"acc":15.0,"spd":0.8},{"t":1794376815000,"lat":25.035848,"lon":121.506485,"acc":15.0,"spd":0.3},{"t":1794376820000,"lat":25.035674,"lon":121.506477,"acc":15.0,"spd":0.5},{"t":1794376825000,"lat":25.035484,"lon":121.506443,"acc":15.0,"spd":0.7},{"t":1794376830000,"lat":25.035842,"lon":121.506703,"acc":15.0,"spd":0.0},{"t":1794376835000,"lat":25.035833,"lon":121.506474,"acc":15.0,"spd":0.5},{"t":1794376840000,"lat":25.03577,"lon":121.506415,"acc":15.0,"spd":0.1},{"t":1794376845000,"lat":25.035678,"lon":121.506523,"acc":15.0,"spd":0.0},{"t":1794376850000,"lat":25.035915,"lon":121.506655,"acc":15.0,"spd":0.0},{"t":1794376855000,"lat":25.035813,"lon":121.506382,"acc":15.0,"spd":0.0},{"t":1794376860000,"lat":25.035618,"lon":121.506603,"acc":15.0,"spd":0.0},{"t":1794376865000,"lat":25.035844,"lon":121.506672,"acc":15.0,"spd":0.2},{"t":1794376870000,"lat":25.036123,"lon":121.506527,"acc":15.0,"spd":4.6},{"t":1794376875000,"lat":25.036079,"lon":121.506785,"acc":15.0,"spd":3.6},{"t":1794376880000,"lat":25.03624,"lon":121.506678,"acc":15.0,"spd":3.4},{"t":1794376885000,"lat":25.03646,"lon":121.506687,"acc":15.0,"spd":4.3},{"t":1794376890000,"lat":25.036826,"lon":121.506687,"acc":15.0,"spd":4.6},{"t":1794376895000,"lat":25.036783,"lon":121.506924,"acc":15.0,"spd":4.1},{"t":1794376900000,"lat":25.036847,"lon":121.506767,"acc":15.0,"spd":4.7},{"t":1794376905000,"lat":25.037426,"lon":121.506883,"acc":15.0,"spd":5.9},{"t":1794376910000,"lat":25.037883,"lon":121.507249,"acc":15.0,"spd":11.4},{"t":1794376915000,"lat":25.038438,"lon":121.507396,"acc":15.0,"spd":11.5},{"t":1794376920000,"lat":25.038883,"lon":121.507563,"acc":15.0,"spd":11.9},{"t":1794376925000,"lat":25.039416,"lon":121.507647,"acc":15.0,"spd":12.2},{"t":1794376930000,"lat":25.039838,"lon":121.507774,"acc":15.0,"spd":12.5},{"t":1794376935000,"lat":25.040522,"lon":121.507672,"acc":15.0,"spd":13.4},{"t":1794376940000,"lat":25.040916,"lon":121.508065,"acc":15.0,"spd":13.5},{"t":1794376945000,"lat":25.041553,"lon":121.508178,"acc":15.0,"spd":10.4},{"t":1794376950000,"lat":25.041415,"lon":121.508178,"acc":15.0,"spd":2.8},{"t":1794376955000,"lat":25.041882,"lon":121.508212,"acc":15.0,"spd":3.6},{"t":1794376960000,"lat":25.041735,"lon":121.50802,"acc":15.0,"spd":3.4},{"t":1794376965000,"lat":25.042065,"lon":121.508387,"acc":15.0,"spd":2.9},{"t":1794376970000,"lat":25.042465,"lon":121.50854,"acc":15.0,"spd":8.9},{"t":1794376975000,"lat":25.043048,"lon":121.508828,"acc":15.0,"spd":11.3},{"t":1794376980000,"lat":25.043518,"lon":121.508592,"acc":15.0,"spd":11.1},{"t":1794376985000,"lat":25.043977,"lon":121.509035,"acc":15.0,"spd":12.6},{"t":1794376990000,"lat":25.044536,"lon":121.508916,"acc":15.0,"spd":10.7},{"t":1794376995000,"lat":25.045043,"lon":121.508989,"acc":15.0,"spd":9.9},{"t":1794377000000,"lat":25.044746,"lon":121.509154,"acc":15.0,"spd":1.8},{"t":1794377005000,"lat":25.045233,"lon":121.509055,"acc":15.0,"spd":1.1},{"t":1794377010000,"lat":25.045062,"lon":121.509151,"acc":15.0,"spd":0.0},{"t":1794377015000,"lat":25.04488,"lon":121.509128,"acc":15.0,"spd":0.3},{"t":1794377020000,"lat":25.045111,"lon":121.509397,"acc":15.0,"spd":0.3},{"t":1794377025000,"lat":25.045161,"lon":121.509191,"acc":15.0,"spd":0.1},{"t":1794377030000,"lat":25.045112,"lon":121.509111,"acc":15.0,"spd":0.0},{"t":1794377035000,"lat":25.04494,"lon":121.509339,"acc":15.0,"spd":0.3},{"t":1794377040000,"lat":25.045008,"lon":121.509286,"acc":15.0,"spd":0.4},{"t":1794377045000,"lat":25.044912,"lon":121.50951,"acc":15.0,"spd":0.5},{"t":1794377050000,"lat":25.045208,"lon":121.509377,"acc":15.0,"spd":0.8},{"t":1794377055000,"lat":25.044941,"lon":121.509272,"acc":15.0,"spd":0.5},{"t":1794377060000,"lat":25.045257,"lon":121.509207,"acc":15.0,"spd":0.0},{"t":1794377065000,"lat":25.044897,"lon":121.50921,"acc":15.0,"spd":0.0},{"t":1794377070000,"lat":25.04527,"lon":121.509243,"acc":15.0,"spd":0.0},{"t":1794377075000,"lat":25.04487,"lon":121.509246,"acc":15.0,"spd":0.4},{"t":1794377080000,"lat":25.045226,"lon":121.509392,"acc":15.0,"spd":0.6},{"t":1794377085000,"lat":25.045019,"lon":121.509117,"acc":15.0,"spd":1.0},{"t":1794377090000,"lat":25.045366,"lon":121.50919,"acc":15.0,"spd":4.6},{"t":1794377095000,"lat":25.045498,"lon":121.509418,"acc":15.0,"spd":4.9},{"t":1794377100000,"lat":25.045682,"lon":121.50943,"acc":15.0,"spd":5.2},{"t":1794377105000,"lat":25.045807,"lon":121.509455,"acc":15.0,"spd":4.9},{"t":1794377110000,"lat":25.046281,"lon":121.509454,"acc":15.0,"spd":4.3},{"t":1794377115000,"lat":25.046227,"lon":121.509761,"acc":15.0,"spd":5.0},{"t":1794377120000,"lat":25.046427,"lon":121.509634,"acc":15.0,"spd":4.7},{"t":1794377125000,"lat":25.046589,"lon":121.509568,"acc":15.0,"spd":3.9},{"t":1794377130000,"lat":25.046673,"lon":121.509822,"acc":15.0,"spd":3.4},{"t":1794377135000,"lat":25.046892,"lon":121.509888,"acc":15.0,"spd":2.8},{"t":1794377140000,"lat":25.046762,"lon":121.509936,"acc":15.0,"spd":3.1},{"t":1794377145000,"lat":25.047143,"lon":121.509864,"acc":15.0,"spd":2.5},{"t":1794377150000,"lat":25.047006,"lon":121.510066,"acc":15.0,"spd":2.5},{"t":1794377155000,"lat":25.047131,"lon":121.510117,"acc":15.0,"spd":2.5},{"t":1794377160000,"lat":25.047352,"lon":121.510179,"acc":15.0,"spd":3.1},{"t":1794377165000,"lat":25.047657,"lon":121.510101,"acc":15.0,"spd":2.1},{"t":1794377170000,"lat":25.047568,"lon":121.510205,"acc":15.0,"spd":1.9},{"t":1794377175000,"lat":25.047639,"lon":121.510025,"acc":15.0,"spd":1.6},{"t":1794377180000,"lat":25.047808,"lon":121.510224,"acc":15.0,"spd":2.4},{"t":1794377185000,"lat":25.047834,"lon":121.510137,"acc":15.0,"spd":1.9},{"t":1794377190000,"lat":25.047882,"lon":121.510354,"acc":15.0,"spd":5.2},{"t":1794377195000,"lat":25.047934,"lon":121.510845,"acc":15.0,"spd":5.6},{"t":1794377200000,"lat":25.047942,"lon":121.511009,"acc":15.0,"spd":5.4},{"t":1794377205000,"lat":25.048014,"lon":121.511387,"acc":15.0,"spd":7.4},{"t":1794377210000,"lat":25.048085,"lon":121.511728,"acc":15.0,"spd":9.5},{"t":1794377215000,"lat":25.047686,"lon":121.512395,"acc":15.0,"spd":9.7},{"t":1794377220000,"lat":25.047585,"lon":121.512664,"acc":15.0,"spd":9.5},{"t":1794377225000,"lat":25.047313,"lon":121.512992,"acc":15.0,"spd":9.5},{"t":1794377230000,"lat":25.047226,"lon":121.513445,"acc":15.0,"spd":9.7},{"t":1794377235000,"lat":25.047021,"lon":121.513749,"acc":15.0,"spd":8.4},{"t":1794377240000,"lat":25.046878,"lon":121.514377,"acc":15.0,"spd":9.0},{"t":1794377245000,"lat":25.046518,"lon":121.514861,"acc":15.0,"spd":8.0},{"t":1794377250000,"lat":25.046796,"lon":121.51479,"acc":15.0,"spd":4.8},{"t":1794377255000,"lat":25.04669,"lon":121.515158,"acc":15.0,"spd":5.1},{"t":1794377260000,"lat":25.046855,"lon":121.515404,"acc":15.0,"spd":4.2},{"t":1794377265000,"lat":25.046632,"lon":121.515648,"acc":15.0,"spd":4.2},{"t":1794377270000,"lat":25.046859,"lon":121.515676,"acc":15.0,"spd":4.0},{"t":1794377275000,"lat":25.046609,"lon":121.51581,"acc":15.0,"spd":4.5},{"t":1794377280000,"lat":25.046829,"lon":121.516099,"acc":15.0,"spd":3.8},{"t":1794377285000,"lat":25.046723,"lon":121.516443,"acc":15.0,"spd":4.6},{"t":1794377290000,"lat":25.046288,"lon":121.516711,"acc":15.0,"spd":7.5},{"t":1794377295000,"lat":25.046333,"lon":121.517337,"acc":15.0,"spd":7.5},{"t":1794377300000,"lat":25.046254,"lon":121.517367,"acc":15.0,"spd":7.4},{"t":1794377305000,"lat":25.045986,"lon":121.518002,"acc":15.0,"spd":7.2},{"t":1794377310000,"lat":25.045948,"lon":121.517953,"acc":15.0,"spd":3.8},{"t":1794377315000,"lat":25.046323,"lon":121.518266,"acc":15.0,"spd":4.8},{"t":1794377320000,"lat":25.045926,"lon":121.518275,"acc":15.0,"spd":3.8},{"t":1794377325000,"lat":25.045711,"lon":121.518586,"acc":15.0,"spd":3.0},{"t":1794377330000,"lat":25.045779,"lon":121.518695,"acc":15.0,"spd":0.6},{"t":1794377335000,"lat":25.04584,"lon":121.518451,"acc":15.0,"spd":1.0},{"t":1794377340000,"lat":25.045934,"lon":121.518602,"acc":15.0,"spd":0.5},{"t":1794377345000,"lat":25.045974,"lon":121.518529,"acc":15.0,"spd":0.5},{"t":1794377350000,"lat":25.045751,"lon":121.518726,"acc":15.0,"spd":0.4},{"t":1794377355000,"lat":25.045981,"lon":121.518677,"acc":15.0,"spd":0.0},{"t":1794377360000,"lat":25.045985,"lon":121.518788,"acc":15.0,"spd":0.2},{"t":1794377365000,"lat":25.045917,"lon":121.518712,"acc":15.0,"spd":0.7},{"t":1794377370000,"lat":25.045852,"lon":121.518786,"acc":15.0,"spd":0.0},{"t":1794377375000,"lat":25.045786,"lon":121.518582,"acc":15.0,"spd":0.0},{"t":1794377380000,"lat":25.045819,"lon":121.518713,"acc":15.0,"spd":0.0},{"t":1794377385000,"lat":25.045848,"lon":121.518831,"acc":15.0,"spd":0.5},{"t":1794377390000,"lat":25.04583,"lon":121.518651,"acc":15.0,"spd":0.1},{"t":1794377395000,"lat":25.04608,"lon":121.51868,"acc":15.0,"spd":0.0},{"t":1794377400000,"lat":25.045776,"lon":121.518562,"acc":15.0,"spd":0.0},{"t":1794377405000,"lat":25.046012,"lon":121.518596,"acc":15.0,"spd":0.0}],"waitingFixes":[{"t":1794376160000,"lat":25.023311,"lon":121.500042,"acc":25.0,"spd":0.0},{"t":1794376165000,"lat":25.023231,"lon":121.49984,"acc":25.0,"spd":0.4},{"t":1794376170000,"lat":25.023557,"lon":121.499695,"acc":25.0,"spd":0.1},{"t":1794376175000,"lat":25.023399,"lon":121.499567,"acc":25.0,"spd":0.1},{"t":1794376180000,"lat":25.023196,"lon":121.499804,"acc":25.0,"spd":0.2},{"t":1794376185000,"lat":25.023219,"lon":121.499852,"acc":25.0,"spd":0.5},{"t":1794376190000,"lat":25.023536,"lon":121.499725,"acc":25.0,"spd":0.2},{"t":1794376195000,"lat":25.023261,"lon":121.499925,"acc":25.0,"spd":0.1},{"t":1794376200000,"lat":25.023453,"lon":121.499876,"acc":25.0,"spd":0.2},{"t":1794376205000,"lat":25.023606,"lon":121.499748,"acc":25.0,"spd":0.2},{"t":1794376210000,"lat":25.023147,"lon":121.499819,"acc":25.0,"spd":0.0},{"t":1794376215000,"lat":25.02335,"lon":121.500639,"acc":25.0,"spd":0.3},{"t":1794376220000,"lat":25.023284,"lon":121.500507,"acc":25.0,"spd":0.0},{"t":1794376225000,"lat":25.023186,"lon":121.50018,"acc":25.0,"spd":0.0},{"t":1794376230000,"lat":25.023186,"lon":121.500005,"acc":25.0,"spd":0.4},{"t":1794376235000,"lat":25.023273,"lon":121.500008,"acc":25.0,"spd":0.0},{"t":1794376240000,"lat":25.023047,"lon":121.499888,"acc":25.0,"spd":0.3},{"t":1794376245000,"lat":25.023167,"lon":121.500006,"acc":25.0,"spd":0.2},{"t":1794376250000,"lat":25.023117,"lon":121.499941,"acc":25.0,"spd":0.0},{"t":1794376255000,"lat":25.023213,"lon":121.499597,"acc":25.0,"spd":0.4},{"t":1794376260000,"lat":25.023398,"lon":121.499727,"acc":25.0,"spd":0.2},{"t":1794376265000,"lat":25.023389,"lon":121.500205,"acc":25.0,"spd":0.1},{"t":1794376270000,"lat":25.023364,"lon":121.499969,"acc":25.0,"spd":0.2},{"t":1794376275000,"lat":25.023188,"lon":121.499899,"acc":25.0,"spd":0.3},{"t":1794376280000,"lat":25.023256,"lon":121.499849,"acc":25.0,"spd":0.0},{"t":1794376285000,"lat":25.023391,"lon":121.499855,"acc":25.0,"spd":0.3},{"t":1794376290000,"lat":25.023191,"lon":121.499876,"acc":25.0,"spd":0.3},{"t":1794376295000,"lat":25.023247,"lon":121.500041,"acc":25.0,"spd":0.2},{"t":1794376300000,"lat":25.023461,"lon":121.49992,"acc":25.0,"spd":0.1},{"t":1794376305000,"lat":25.023336,"lon":121.499843,"acc":25.0,"spd":0.2},{"t":1794376310000,"lat":25.02322,"lon":121.500011,"acc":25.0,"spd":0.2},{"t":1794376315000,"lat":25.023402,"lon":121.499867,"acc":25.0,"spd":0.9},{"t":1794376320000,"lat":25.023349,"lon":121.500005,"acc":25.0,"spd":0.1},{"t":1794376325000,"lat":25.023103,"lon":121.499609,"acc":25.0,"spd":0.7},{"t":1794376330000,"lat":25.023021,"lon":121.499862,"acc":25.0,"spd":0.3},{"t":1794376335000,"lat":25.022975,"lon":121.499778,"acc":25.0,"spd":0.5},{"t":1794376340000,"lat":25.023328,"lon":121.499778,"acc":25.0,"spd":0.4},{"t":1794376345000,"lat":25.023205,"lon":121.499927,"acc":25.0,"spd":0.2},{"t":1794376350000,"lat":25.023452,"lon":121.499798,"acc":25.0,"spd":0.3},{"t":1794376355000,"lat":25.023139,"lon":121.500159,"acc":25.0,"spd":0.3},{"t":1794376360000,"lat":25.023274,"lon":121.499814,"acc":25.0,"spd":0.1},{"t":1794376365000,"lat":25.02359,"lon":121.499865,"acc":25.0,"spd":0.3},{"t":1794376370000,"lat":25.023283,"lon":121.499935,"acc":25.0,"spd":0.3},{"t":1794376375000,"lat":25.023457,"lon":121.499893,"acc":25.0,"spd":0.1},{"t":1794376380000,"lat":25.023407,"lon":121.4998,"acc":25.0,"spd":0.1},{"t":1794376385000,"lat":25.023095,"lon":121.499884,"acc":25.0,"spd":0.0},{"t":1794376390000,"lat":25.023207,"lon":121.499771,"acc":25.0,"spd":0.3},{"t":1794376395000,"lat":25.023232,"lon":121.49971,"acc":25.0,"spd":0.1},{"t":1794376400000,"lat":25.023522,"lon":121.500121,"acc":25.0,"spd":0.5},{"t":1794376405000,"lat":25.023132,"lon":121.499814,"acc":25.0,"spd":0.1},{"t":1794376410000,"lat":25.023445,"lon":121.499886,"acc":25.0,"spd":0.4},{"t":1794376415000,"lat":25.023338,"lon":121.499751,"acc":25.0,"spd":0.1},{"t":1794376420000,"lat":25.023377,"lon":121.499589,"acc":25.0,"spd":0.0},{"t":1794376425000,"lat":25.023199,"lon":121.499915,"acc":25.0,"spd":0.3},{"t":1794376430000,"lat":25.023318,"lon":121.500031,"acc":25.0,"spd":0.0},{"t":1794376435000,"lat":25.023239,"lon":121.499873,"acc":25.0,"spd":0.3},{"t":1794376440000,"lat":25.023261,"lon":121.499946,"acc":25.0,"spd":0.2},{"t":1794376445000,"lat":25.023401,"lon":121.500159,"acc":25.0,"spd":0.1},{"t":1794376450000,"lat":25.023377,"lon":121.499905,"acc":25.0,"spd":0.3},{"t":1794376455000,"lat":25.023274,"lon":121.499809,"acc":25.0,"spd":0.4},{"t":1794376460000,"lat":25.023193,"lon":121.499928,"acc":25.0,"spd":0.2},{"t":1794376465000,"lat":25.023425,"lon":121.499902,"acc":25.0,"spd":0.1},{"t":1794376470000,"lat":25.023375,"lon":121.5003,"acc":25.0,"spd":0.7},{"t":1794376475000,"lat":25.023314,"lon":121.499835,"acc":25.0,"spd":0.1},{"t":1794376480000,"lat":25.023475,"lon":121.500059,"acc":25.0,"spd":0.3},{"t":1794376485000,"lat":25.023494,"lon":121.49961,"acc":25.0,"spd":0.1},{"t":1794376490000,"lat":25.02305,"lon":121.500057,"acc":25.0,"spd":0.5},{"t":1794376495000,"lat":25.023145,"lon":121.49997,"acc":25.0,"spd":0.0},{"t":1794376500000,"lat":25.023219,"lon":121.499617,"acc":25.0,"spd":0.4},{"t":1794376505000,"lat":25.023147,"lon":121.50004,"acc":25.0,"spd":0.0},{"t":1794376510000,"lat":25.023342,"lon":121.500045,"acc":25.0,"spd":0.4},{"t":1794376515000,"lat":25.023159,"lon":121.5,"acc":25.0,"spd":0.7},{"t":1794376520000,"lat":25.023072,"lon":121.499864,"acc":25.0,"spd":0.3},{"t":1794376525000,"lat":25.02339,"lon":121.500027,"acc":25.0,"spd":0.2},{"t":1794376530000,"lat":25.023303,"lon":121.49984,"acc":25.0,"spd":0.0},{"t":1794376535000,"lat":25.023469,"lon":121.499722,"acc":25.0,"spd":0.3},{"t":1794376540000,"lat":25.02329,"lon":121.499936,"acc":25.0,"spd":0.2},{"t":1794376545000,"lat":25.023468,"lon":121.499969,"acc":25.0,"spd":0.5},{"t":1794376550000,"lat":25.023367,"lon":121.499887,"acc":25.0,"spd":0.0},{"t":1794376555000,"lat":25.023442,"lon":121.500642,"acc":25.0,"spd":0.1},{"t":1794376560000,"lat":25.023279,"lon":121.500215,"acc":25.0,"spd":0.1},{"t":1794376565000,"lat":25.023378,"lon":121.499783,"acc":25.0,"spd":0.1},{"t":1794376570000,"lat":25.023389,"lon":121.499954,"acc":25.0,"spd":0.1},{"t":1794376575000,"lat":25.023308,"lon":121.5001,"acc":25.0,"spd":0.1},{"t":1794376580000,"lat":25.023432,"lon":121.499771,"acc":25.0,"spd":0.1},{"t":1794376585000,"lat":25.023135,"lon":121.49995,"acc":25.0,"spd":0.2},{"t":1794376590000,"lat":25.023133,"lon":121.499705,"acc":25.0,"spd":0.6},{"t":1794376595000,"lat":25.023283,"lon":121.500084,"acc":25.0,"spd":0.2},{"t":1794376600000,"lat":25.023066,"lon":121.500003,"acc":25.0,"spd":0.3},{"t":1794376605000,"lat":25.0235,"lon":121.499851,"acc":25.0,"spd":0.1},{"t":1794376610000,"lat":25.023366,"lon":121.499842,"acc":25.0,"spd":0.1},{"t":1794376615000,"lat":25.02336,"lon":121.4998,"acc":25.0,"spd":0.2},{"t":1794376620000,"lat":25.023392,"lon":121.499981,"acc":25.0,"spd":0.4},{"t":1794376625000,"lat":25.02346,"lon":121.499873,"acc":25.0,"spd":0.2},{"t":1794376630000,"lat":25.023319,"lon":121.499884,"acc":25.0,"spd":0.1},{"t":1794376635000,"lat":25.023262,"lon":121.499752,"acc":25.0,"spd":0.2},{"t":1794376640000,"lat":25.023369,"lon":121.499993,"acc":25.0,"spd":0.1},{"t":1794376645000,"lat":25.023178,"lon":121.499857,"acc":25.0,"spd":0.3},{"t":1794376650000,"lat":25.023336,"lon":121.499856,"acc":25.0,"spd":0.2},{"t":1794376655000,"lat":25.023381,"lon":121.499884,"acc":25.0,"spd":0.8},{"t":1794376660000,"lat":25.023225,"lon":121.499701,"acc":25.0,"spd":0.3},{"t":1794376665000,"lat":25.023285,"lon":121.49975,"acc":25.0,"spd":0.1},{"t":1794376670000,"lat":25.023282,"lon":121.499789,"acc":25.0,"spd":0.2},{"t":1794376675000,"lat":25.023314,"lon":121.499713,"acc":25.0,"spd":0.6},{"t":1794376680000,"lat":25.023539,"lon":121.500024,"acc":25.0,"spd":0.2},{"t":1794376685000,"lat":25.023132,"lon":121.500052,"acc":25.0,"spd":0.2},{"t":1794376690000,"lat":25.023315,"lon":121.499755,"acc":25.0,"spd":0.3},{"t":1794376695000,"lat":25.023211,"lon":121.500056,"acc":25.0,"spd":0.0},{"t":1794376700000,"lat":25.023009,"lon":121.500105,"acc":25.0,"spd":0.1},{"t":1794376705000,"lat":25.023303,"lon":121.500175,"acc":25.0,"spd":0.1},{"t":1794376710000,"lat":25.023497,"lon":121.49976,"acc":25.0,"spd":0.1},{"t":1794376715000,"lat":25.023426,"lon":121.499616,"acc":25.0,"spd":0.1},{"t":1794376720000,"lat":25.023604,"lon":121.499934,"acc":25.0,"spd":0.1},{"t":1794376725000,"lat":25.023666,"lon":121.499803,"acc":25.0,"spd":0.2},{"t":1794376730000,"lat":25.023326,"lon":121.499853,"acc":25.0,"spd":0.7},{"t":1794376735000,"lat":25.023222,"lon":121.499966,"acc":25.0,"spd":0.3},{"t":1794376740000,"lat":25.023093,"lon":121.499932,"acc":25.0,"spd":0.4},{"t":1794376745000,"lat":25.023174,"lon":121.499847,"acc":25.0,"spd":0.2},{"t":1794376750000,"lat":25.023392,"lon":121.500111,"acc":25.0,"spd":0.4},{"t":1794376755000,"lat":25.023169,"lon":121.500004,"acc":25.0,"spd":0.2},{"t":1794376760000,"lat":25.023206,"lon":121.499882,"acc":25.0,"spd":0.0},{"t":1794376765000,"lat":25.023314,"lon":121.499711,"acc":25.0,"spd":0.2},{"t":1794376770000,"lat":25.023198,"lon":121.500034,"acc":25.0,"spd":0.6},{"t":1794376775000,"lat":25.023347,"lon":121.500552,"acc":25.0,"spd":0.2},{"t":1794376780000,"lat":25.023411,"lon":121.500214,"acc":25.0,"spd":0.2},{"t":1794376785000,"lat":25.023257,"lon":121.499975,"acc":25.0,"spd":0.3},{"t":1794376790000,"lat":25.023386,"lon":121.500085,"acc":25.0,"spd":0.2},{"t":1794376795000,"lat":25.023393,"lon":121.499934,"acc":25.0,"spd":0.2},{"t":1794376800000,"lat":25.023325,"lon":121.4999,"acc":25.0,"spd":0.1},{"t":1794376805000,"lat":25.023219,"lon":121.499726,"acc":25.0,"spd":0.3},{"t":1794376810000,"lat":25.023581,"lon":121.499278,"acc":25.0,"spd":0.4},{"t":1794376815000,"lat":25.023345,"lon":121.499813,"acc":25.0,"spd":0.5},{"t":1794376820000,"lat":25.023387,"lon":121.499801,"acc":25.0,"spd":0.1},{"t":1794376825000,"lat":25.023521,"lon":121.499794,"acc":25.0,"spd":0.2},{"t":1794376830000,"lat":25.023344,"lon":121.500203,"acc":25.0,"spd":0.3},{"t":1794376835000,"lat":25.023377,"lon":121.499813,"acc":25.0,"spd":0.1},{"t":1794376840000,"lat":25.023081,"lon":121.500434,"acc":25.0,"spd":0.0},{"t":1794376845000,"lat":25.023416,"lon":121.500219,"acc":25.0,"spd":0.2},{"t":1794376850000,"lat":25.023175,"lon":121.500189,"acc":25.0,"spd":0.0},{"t":1794376855000,"lat":25.023296,"lon":121.499444,"acc":25.0,"spd":0.1},{"t":1794376860000,"lat":25.023253,"lon":121.49986,"acc":25.0,"spd":0.1},{"t":1794376865000,"lat":25.023589,"lon":121.499982,"acc":25.0,"spd":0.2},{"t":1794376870000,"lat":25.023321,"lon":121.499954,"acc":25.0,"spd":0.3},{"t":1794376875000,"lat":25.02335,"lon":121.499886,"acc":25.0,"spd":0.0},{"t":1794376880000,"lat":25.023181,"lon":121.499433,"acc":25.0,"spd":0.2},{"t":1794376885000,"lat":25.023466,"lon":121.49969,"acc":25.0,"spd":0.4},{"t":1794376890000,"lat":25.023428,"lon":121.500124,"acc":25.0,"spd":0.1},{"t":1794376895000,"lat":25.023298,"lon":121.499842,"acc":25.0,"spd":0.2},{"t":1794376900000,"lat":25.023224,"lon":121.500051,"acc":25.0,"spd":0.0},{"t":1794376905000,"lat":25.023465,"lon":121.500019,"acc":25.0,"spd":0.2},{"t":1794376910000,"lat":25.023306,"lon":121.49982,"acc":25.0,"spd":0.1},{"t":1794376915000,"lat":25.023385,"lon":121.500053,"acc":25.0,"spd":0.1},{"t":1794376920000,"lat":25.023259,"lon":121.499793,"acc":25.0,"spd":0.4},{"t":1794376925000,"lat":25.023144,"lon":121.499926,"acc":25.0,"spd":0.0},{"t":1794376930000,"lat":25.023411,"lon":121.499925,"acc":25.0,"spd":0.2},{"t":1794376935000,"lat":25.023349,"lon":121.49996,"acc":25.0,"spd":0.2},{"t":1794376940000,"lat":25.023436,"lon":121.500024,"acc":25.0,"spd":0.0},{"t":1794376945000,"lat":25.02355,"lon":121.499843,"acc":25.0,"spd":0.1},{"t":1794376950000,"lat":25.023344,"lon":121.500062,"acc":25.0,"spd":0.0},{"t":1794376955000,"lat":25.023409,"lon":121.499839,"acc":25.0,"spd":0.2},{"t":1794376960000,"lat":25.0232,"lon":121.500077,"acc":25.0,"spd":0.3},{"t":1794376965000,"lat":25.023212,"lon":121.499844,"acc":25.0,"spd":0.1},{"t":1794376970000,"lat":25.023223,"lon":121.500112,"acc":25.0,"spd":0.3},{"t":1794376975000,"lat":25.023312,"lon":121.500142,"acc":25.0,"spd":0.4},{"t":1794376980000,"lat":25.023253,"lon":121.499669,"acc":25.0,"spd":0.1},{"t":1794376985000,"lat":25.023411,"lon":121.499859,"acc":25.0,"spd":0.0},{"t":1794376990000,"lat":25.023423,"lon":121.5,"acc":25.0,"spd":0.6},{"t":1794376995000,"lat":25.023199,"lon":121.499946,"acc":25.0,"spd":0.3},{"t":1794377000000,"lat":25.023382,"lon":121.499893,"acc":25.0,"spd":0.5},{"t":1794377005000,"lat":25.023273,"lon":121.500243,"acc":25.0,"spd":0.2},{"t":1794377010000,"lat":25.023232,"lon":121.499887,"acc":25.0,"spd":0.4},{"t":1794377015000,"lat":25.023275,"lon":121.500062,"acc":25.0,"spd":0.0},{"t":1794377020000,"lat":25.023352,"lon":121.500046,"acc":25.0,"spd":0.3},{"t":1794377025000,"lat":25.023451,"lon":121.499813,"acc":25.0,"spd":0.3},{"t":1794377030000,"lat":25.023267,"lon":121.499761,"acc":25.0,"spd":0.0},{"t":1794377035000,"lat":25.022994,"lon":121.499238,"acc":25.0,"spd":0.0},{"t":1794377040000,"lat":25.023306,"lon":121.499519,"acc":25.0,"spd":0.0},{"t":1794377045000,"lat":25.023358,"lon":121.499821,"acc":25.0,"spd":0.0},{"t":1794377050000,"lat":25.023335,"lon":121.499788,"acc":25.0,"spd":0.3},{"t":1794377055000,"lat":25.023325,"lon":121.499806,"acc":25.0,"spd":0.3},{"t":1794377060000,"lat":25.023297,"lon":121.499672,"acc":25.0,"spd":0.2},{"t":1794377065000,"lat":25.023484,"lon":121.499965,"acc":25.0,"spd":0.4},{"t":1794377070000,"lat":25.023455,"lon":121.500001,"acc":25.0,"spd":0.3},{"t":1794377075000,"lat":25.023139,"lon":121.49998,"acc":25.0,"spd":0.2},{"t":1794377080000,"lat":25.023241,"lon":121.500298,"acc":25.0,"spd":0.2},{"t":1794377085000,"lat":25.023275,"lon":121.500115,"acc":25.0,"spd":0.3},{"t":1794377090000,"lat":25.023384,"lon":121.500094,"acc":25.0,"spd":0.3},{"t":1794377095000,"lat":25.023263,"lon":121.499778,"acc":25.0,"spd":0.1},{"t":1794377100000,"lat":25.023151,"lon":121.499983,"acc":25.0,"spd":0.3},{"t":1794377105000,"lat":25.023048,"lon":121.499125,"acc":25.0,"spd":0.0},{"t":1794377110000,"lat":25.023071,"lon":121.499645,"acc":25.0,"spd":0.5},{"t":1794377115000,"lat":25.023125,"lon":121.499834,"acc":25.0,"spd":0.1},{"t":1794377120000,"lat":25.023437,"lon":121.499959,"acc":25.0,"spd":0.4},{"t":1794377125000,"lat":25.023435,"lon":121.499844,"acc":25.0,"spd":0.6},{"t":1794377130000,"lat":25.023459,"lon":121.499623,"acc":25.0,"spd":0.4},{"t":1794377135000,"lat":25.023231,"lon":121.499879,"acc":25.0,"spd":0.1},{"t":1794377140000,"lat":25.023223,"lon":121.49984,"acc":25.0,"spd":0.5},{"t":1794377145000,"lat":25.023322,"lon":121.499889,"acc":25.0,"spd":0.1},{"t":1794377150000,"lat":25.023386,"lon":121.49975,"acc":25.0,"spd":0.3},{"t":1794377155000,"lat":25.023498,"lon":121.499884,"acc":25.0,"spd":0.1},{"t":1794377160000,"lat":25.023254,"lon":121.499831,"acc":25.0,"spd":0.2},{"t":1794377165000,"lat":25.023457,"lon":121.500038,"acc":25.0,"spd":0.5},{"t":1794377170000,"lat":25.023129,"lon":121.499703,"acc":25.0,"spd":0.1},{"t":1794377175000,"lat":25.023253,"lon":121.499938,"acc":25.0,"spd":0.4},{"t":1794377180000,"lat":25.023325,"lon":121.499694,"acc":25.0,"spd":0.1},{"t":1794377185000,"lat":25.023239,"lon":121.499908,"acc":25.0,"spd":0.2},{"t":1794377190000,"lat":25.023298,"lon":121.500136,"acc":25.0,"spd":0.0},{"t":1794377195000,"lat":25.023177,"lon":121.499833,"acc":25.0,"spd":0.4},{"t":1794377200000,"lat":25.023065,"lon":121.499728,"acc":25.0,"spd":0.0},{"t":1794377205000,"lat":25.023128,"lon":121.499975,"acc":25.0,"spd":0.4},{"t":1794377210000,"lat":25.023422,"lon":121.499846,"acc":25.0,"spd":0.4},{"t":1794377215000,"lat":25.023246,"lon":121.499749,"acc":25.0,"spd":0.3},{"t":1794377220000,"lat":25.023348,"lon":121.499783,"acc":25.0,"spd":0.3},{"t":1794377225000,"lat":25.02324,"lon":121.499949,"acc":25.0,"spd":0.4},{"t":1794377230000,"lat":25.023288,"lon":121.499608,"acc":25.0,"spd":0.7},{"t":1794377235000,"lat":25.023394,"lon":121.49997,"acc":25.0,"spd":0.5},{"t":1794377240000,"lat":25.023433,"lon":121.499912,"acc":25.0,"spd":0.1},{"t":1794377245000,"lat":25.023126,"lon":121.500041,"acc":25.0,"spd":0.2},{"t":1794377250000,"lat":25.023583,"lon":121.500205,"acc":25.0,"spd":0.5},{"t":1794377255000,"lat":25.02323,"lon":121.500002,"acc":25.0,"spd":0.2},{"t":1794377260000,"lat":25.023246,"lon":121.500033,"acc":25.0,"spd":0.2},{"t":1794377265000,"lat":25.023337,"lon":121.499957,"acc":25.0,"spd":0.3},{"t":1794377270000,"lat":25.023432,"lon":121.499907,"acc":25.0,"spd":0.2},{"t":1794377275000,"lat":25.023326,"lon":121.499997,"acc":25.0,"spd":0.3},{"t":1794377280000,"lat":25.023357,"lon":121.499979,"acc":25.0,"spd":0.1},{"t":1794377285000,"lat":25.023433,"lon":121.499812,"acc":25.0,"spd":0.0},{"t":1794377290000,"lat":25.023279,"lon":121.49995,"acc":25.0,"spd":0.1},{"t":1794377295000,"lat":25.023205,"lon":121.500524,"acc":25.0,"spd":0.2},{"t":1794377300000,"lat":25.023188,"lon":121.500237,"acc":25.0,"spd":0.2},{"t":1794377305000,"lat":25.02329,"lon":121.500248,"acc":25.0,"spd":0.2},{"t":1794377310000,"lat":25.023356,"lon":121.500149,"acc":25.0,"spd":0.3},{"t":1794377315000,"lat":25.023182,"lon":121.499947,"acc":25.0,"spd":0.4},{"t":1794377320000,"lat":25.023427,"lon":121.499689,"acc":25.0,"spd":0.3},{"t":1794377325000,"lat":25.023371,"lon":121.499939,"acc":25.0,"spd":0.6},{"t":1794377330000,"lat":25.023317,"lon":121.500015,"acc":25.0,"spd":0.3},{"t":1794377335000,"lat":25.023177,"lon":121.500104,"acc":25.0,"spd":0.2},{"t":1794377340000,"lat":25.023165,"lon":121.499752,"acc":25.0,"spd":0.0},{"t":1794377345000,"lat":25.023436,"lon":121.49986,"acc":25.0,"spd":0.7},{"t":1794377350000,"lat":25.023442,"lon":121.500082,"acc":25.0,"spd":0.2},{"t":1794377355000,"lat":25.02305,"lon":121.499771,"acc":25.0,"spd":0.5},{"t":1794377360000,"lat":25.02346,"lon":121.499731,"acc":25.0,"spd":0.4},{"t":1794377365000,"lat":25.023336,"lon":121.499992,"acc":25.0,"spd":0.0},{"t":1794377370000,"lat":25.023137,"lon":121.499848,"acc":25.0,"spd":0.0},{"t":1794377375000,"lat":25.023208,"lon":121.5001,"acc":25.0,"spd":0.6},{"t":1794377380000,"lat":25.023236,"lon":121.499846,"acc":25.0,"spd":0.0},{"t":1794377385000,"lat":25.02343,"lon":121.499758,"acc":25.0,"spd":0.2},{"t":1794377390000,"lat":25.023232,"lon":121.499838,"acc":25.0,"spd":0.2},{"t":1794377395000,"lat":25.023117,"lon":121.499873,"acc":25.0,"spd":0.3},{"t":1794377400000,"lat":25.023145,"lon":121.499901,"acc":25.0,"spd":0.4},{"t":1794377405000,"lat":25.023442,"lon":121.499928,"acc":25.0,"spd":0.0}],"walkerFixes":[{"t":1794376160000,"lat":25.023229,"lon":121.499946,"acc":15.0,"spd":1.5},{"t":1794376165000,"lat":25.023184,"lon":121.499738,"acc":15.0,"spd":1.4},{"t":1794376170000,"lat":25.02345,"lon":121.499751,"acc":15.0,"spd":1.6},{"t":1794376175000,"lat":25.023531,"lon":121.499921,"acc":15.0,"spd":1.5},{"t":1794376180000,"lat":25.023468,"lon":121.499843,"acc":15.0,"spd":1.6},{"t":1794376185000,"lat":25.023718,"lon":121.499763,"acc":15.0,"spd":1.3},{"t":1794376190000,"lat":25.023659,"lon":121.50007,"acc":15.0,"spd":1.5},{"t":1794376195000,"lat":25.023703,"lon":121.500016,"acc":15.0,"spd":1.2},{"t":1794376200000,"lat":25.023796,"lon":121.500137,"acc":15.0,"spd":1.2},{"t":1794376205000,"lat":25.023715,"lon":121.50001,"acc":15.0,"spd":1.5},{"t":1794376210000,"lat":25.023824,"lon":121.500084,"acc":15.0,"spd":1.2},{"t":1794376215000,"lat":25.023913,"lon":121.500246,"acc":15.0,"spd":1.6},{"t":1794376220000,"lat":25.024135,"lon":121.500027,"acc":15.0,"spd":1.3},{"t":1794376225000,"lat":25.024195,"lon":121.500107,"acc":15.0,"spd":0.9},{"t":1794376230000,"lat":25.024104,"lon":121.50015,"acc":15.0,"spd":1.0},{"t":1794376235000,"lat":25.024125,"lon":121.500118,"acc":15.0,"spd":1.3},{"t":1794376240000,"lat":25.024436,"lon":121.500102,"acc":15.0,"spd":1.6},{"t":1794376245000,"lat":25.024297,"lon":121.500169,"acc":15.0,"spd":1.4},{"t":1794376250000,"lat":25.024369,"lon":121.500451,"acc":15.0,"spd":1.6},{"t":1794376255000,"lat":25.024498,"lon":121.500103,"acc":15.0,"spd":1.4},{"t":1794376260000,"lat":25.024543,"lon":121.500433,"acc":15.0,"spd":1.6},{"t":1794376265000,"lat":25.024698,"lon":121.500364,"acc":15.0,"spd":1.4},{"t":1794376270000,"lat":25.02485,"lon":121.500306,"acc":15.0,"spd":1.7},{"t":1794376275000,"lat":25.024776,"lon":121.500266,"acc":15.0,"spd":1.5},{"t":1794376280000,"lat":25.024618,"lon":121.500351,"acc":15.0,"spd":1.3},{"t":1794376285000,"lat":25.024812,"lon":121.500395,"acc":15.0,"spd":1.4},{"t":1794376290000,"lat":25.024841,"lon":121.500401,"acc":15.0,"spd":1.4},{"t":1794376295000,"lat":25.024908,"lon":121.500454,"acc":15.0,"spd":1.7},{"t":1794376300000,"lat":25.024946,"lon":121.500507,"acc":15.0,"spd":1.2},{"t":1794376305000,"lat":25.025112,"lon":121.500618,"acc":15.0,"spd":1.1},{"t":1794376310000,"lat":25.02502,"lon":121.500533,"acc":15.0,"spd":1.4},{"t":1794376315000,"lat":25.02505,"lon":121.500699,"acc":15.0,"spd":1.3},{"t":1794376320000,"lat":25.025417,"lon":121.500867,"acc":15.0,"spd":1.3},{"t":1794376325000,"lat":25.025242,"lon":121.500385,"acc":15.0,"spd":1.5},{"t":1794376330000,"lat":25.025303,"lon":121.500467,"acc":15.0,"spd":1.1},{"t":1794376335000,"lat":25.025543,"lon":121.500375,"acc":15.0,"spd":1.7},{"t":1794376340000,"lat":25.025372,"lon":121.500597,"acc":15.0,"spd":0.9},{"t":1794376345000,"lat":25.025334,"lon":121.500463,"acc":15.0,"spd":1.4},{"t":1794376350000,"lat":25.025672,"lon":121.500871,"acc":15.0,"spd":1.8},{"t":1794376355000,"lat":25.025745,"lon":121.500339,"acc":15.0,"spd":1.5},{"t":1794376360000,"lat":25.025712,"lon":121.500573,"acc":15.0,"spd":1.5},{"t":1794376365000,"lat":25.025792,"lon":121.500538,"acc":15.0,"spd":1.2},{"t":1794376370000,"lat":25.025932,"lon":121.500584,"acc":15.0,"spd":1.5},{"t":1794376375000,"lat":25.025847,"lon":121.500342,"acc":15.0,"spd":1.6},{"t":1794376380000,"lat":25.025937,"lon":121.500381,"acc":15.0,"spd":1.3},{"t":1794376385000,"lat":25.025886,"lon":121.500449,"acc":15.0,"spd":1.7},{"t":1794376390000,"lat":25.026183,"lon":121.500494,"acc":15.0,"spd":1.1},{"t":1794376395000,"lat":25.026175,"lon":121.500554,"acc":15.0,"spd":1.5},{"t":1794376400000,"lat":25.026305,"lon":121.500317,"acc":15.0,"spd":1.6},{"t":1794376405000,"lat":25.02626,"lon":121.500493,"acc":15.0,"spd":1.2},{"t":1794376410000,"lat":25.02635,"lon":121.500456,"acc":15.0,"spd":1.6},{"t":1794376415000,"lat":25.026509,"lon":121.500433,"acc":15.0,"spd":1.3},{"t":1794376420000,"lat":25.026455,"lon":121.500492,"acc":15.0,"spd":1.4},{"t":1794376425000,"lat":25.026607,"lon":121.500281,"acc":15.0,"spd":1.4},{"t":1794376430000,"lat":25.026532,"lon":121.50043,"acc":15.0,"spd":1.4},{"t":1794376435000,"lat":25.026726,"lon":121.500361,"acc":15.0,"spd":1.4},{"t":1794376440000,"lat":25.02692,"lon":121.500453,"acc":15.0,"spd":1.1},{"t":1794376445000,"lat":25.026927,"lon":121.500673,"acc":15.0,"spd":1.4},{"t":1794376450000,"lat":25.026956,"lon":121.500464,"acc":15.0,"spd":1.8},{"t":1794376455000,"lat":25.02678,"lon":121.500565,"acc":15.0,"spd":1.4},{"t":1794376460000,"lat":25.027033,"lon":121.50073,"acc":15.0,"spd":1.4},{"t":1794376465000,"lat":25.027239,"lon":121.500647,"acc":15.0,"spd":1.7},{"t":1794376470000,"lat":25.027133,"lon":121.500397,"acc":15.0,"spd":1.6},{"t":1794376475000,"lat":25.027177,"lon":121.500513,"acc":15.0,"spd":1.4},{"t":1794376480000,"lat":25.027354,"lon":121.50048,"acc":15.0,"spd":1.2},{"t":1794376485000,"lat":25.027175,"lon":121.500667,"acc":15.0,"spd":1.4},{"t":1794376490000,"lat":25.027428,"lon":121.500509,"acc":15.0,"spd":1.5},{"t":1794376495000,"lat":25.027351,"lon":121.500295,"acc":15.0,"spd":0.9},{"t":1794376500000,"lat":25.027634,"lon":121.500492,"acc":15.0,"spd":1.4},{"t":1794376505000,"lat":25.027629,"lon":121.500563,"acc":15.0,"spd":1.7},{"t":1794376510000,"lat":25.027591,"lon":121.50084,"acc":15.0,"spd":1.8},{"t":1794376515000,"lat":25.027595,"lon":121.500559,"acc":15.0,"spd":1.8},{"t":1794376520000,"lat":25.027907,"lon":121.500798,"acc":15.0,"spd":1.4},{"t":1794376525000,"lat":25.027685,"lon":121.50041,"acc":15.0,"spd":1.6},{"t":1794376530000,"lat":25.028027,"lon":121.50052,"acc":15.0,"spd":1.3},{"t":1794376535000,"lat":25.027941,"lon":121.500633,"acc":15.0,"spd":1.8},{"t":1794376540000,"lat":25.028221,"lon":121.500624,"acc":15.0,"spd":1.4},{"t":1794376545000,"lat":25.028189,"lon":121.500714,"acc":15.0,"spd":1.4},{"t":1794376550000,"lat":25.028129,"lon":121.500538,"acc":15.0,"spd":1.3},{"t":1794376555000,"lat":25.028248,"lon":121.500699,"acc":15.0,"spd":1.5},{"t":1794376560000,"lat":25.028368,"lon":121.500534,"acc":15.0,"spd":1.2},{"t":1794376565000,"lat":25.028328,"lon":121.500493,"acc":15.0,"spd":1.8},{"t":1794376570000,"lat":25.028261,"lon":121.500605,"acc":15.0,"spd":1.7},{"t":1794376575000,"lat":25.028398,"lon":121.500623,"acc":15.0,"spd":1.4},{"t":1794376580000,"lat":25.028391,"lon":121.500468,"acc":15.0,"spd":1.7},{"t":1794376585000,"lat":25.028558,"lon":121.500462,"acc":15.0,"spd":1.5},{"t":1794376590000,"lat":25.028498,"lon":121.500659,"acc":15.0,"spd":1.2},{"t":1794376595000,"lat":25.028509,"lon":121.500658,"acc":15.0,"spd":1.5},{"t":1794376600000,"lat":25.028537,"lon":121.500728,"acc":15.0,"spd":1.4},{"t":1794376605000,"lat":25.028771,"lon":121.500571,"acc":15.0,"spd":1.2},{"t":1794376610000,"lat":25.029022,"lon":121.500436,"acc":15.0,"spd":1.3},{"t":1794376615000,"lat":25.028992,"lon":121.500669,"acc":15.0,"spd":1.4},{"t":1794376620000,"lat":25.029001,"lon":121.500609,"acc":15.0,"spd":1.3},{"t":1794376625000,"lat":25.029132,"lon":121.50063,"acc":15.0,"spd":1.4},{"t":1794376630000,"lat":25.02907,"lon":121.50061,"acc":15.0,"spd":1.5},{"t":1794376635000,"lat":25.029288,"lon":121.500681,"acc":15.0,"spd":1.4},{"t":1794376640000,"lat":25.029126,"lon":121.500983,"acc":15.0,"spd":1.5},{"t":1794376645000,"lat":25.029324,"lon":121.500624,"acc":15.0,"spd":1.1},{"t":1794376650000,"lat":25.029248,"lon":121.500381,"acc":15.0,"spd":1.5},{"t":1794376655000,"lat":25.029266,"lon":121.500719,"acc":15.0,"spd":1.6},{"t":1794376660000,"lat":25.029467,"lon":121.500618,"acc":15.0,"spd":1.2},{"t":1794376665000,"lat":25.029332,"lon":121.500716,"acc":15.0,"spd":1.1},{"t":1794376670000,"lat":25.02964,"lon":121.500422,"acc":15.0,"spd":1.2},{"t":1794376675000,"lat":25.029588,"lon":121.500369,"acc":15.0,"spd":1.4},{"t":1794376680000,"lat":25.02997,"lon":121.500367,"acc":15.0,"spd":1.3},{"t":1794376685000,"lat":25.02972,"lon":121.500695,"acc":15.0,"spd":1.4},{"t":1794376690000,"lat":25.029853,"lon":121.50057,"acc":15.0,"spd":1.6},{"t":1794376695000,"lat":25.030081,"lon":121.500398,"acc":15.0,"spd":1.0},{"t":1794376700000,"lat":25.030087,"lon":121.500604,"acc":15.0,"spd":1.5},{"t":1794376705000,"lat":25.029982,"lon":121.500709,"acc":15.0,"spd":1.6},{"t":1794376710000,"lat":25.030203,"lon":121.500735,"acc":15.0,"spd":1.4},{"t":1794376715000,"lat":25.030082,"lon":121.50053,"acc":15.0,"spd":1.5},{"t":1794376720000,"lat":25.030249,"lon":121.500472,"acc":15.0,"spd":1.5},{"t":1794376725000,"lat":25.03036,"lon":121.500503,"acc":15.0,"spd":1.6},{"t":1794376730000,"lat":25.030257,"lon":121.50075,"acc":15.0,"spd":1.4},{"t":1794376735000,"lat":25.030634,"lon":121.500377,"acc":15.0,"spd":1.7},{"t":1794376740000,"lat":25.03049,"lon":121.50065,"acc":15.0,"spd":1.2},{"t":1794376745000,"lat":25.030454,"lon":121.500685,"acc":15.0,"spd":1.5},{"t":1794376750000,"lat":25.030731,"lon":121.500516,"acc":15.0,"spd":1.2},{"t":1794376755000,"lat":25.030843,"lon":121.500589,"acc":15.0,"spd":1.2},{"t":1794376760000,"lat":25.030677,"lon":121.500612,"acc":15.0,"spd":1.4},{"t":1794376765000,"lat":25.030668,"lon":121.500571,"acc":15.0,"spd":1.4},{"t":1794376770000,"lat":25.030733,"lon":121.500848,"acc":15.0,"spd":1.8},{"t":1794376775000,"lat":25.030812,"lon":121.501014,"acc":15.0,"spd":1.2},{"t":1794376780000,"lat":25.030976,"lon":121.500747,"acc":15.0,"spd":1.2},{"t":1794376785000,"lat":25.030823,"lon":121.501041,"acc":15.0,"spd":1.5},{"t":1794376790000,"lat":25.030952,"lon":121.501079,"acc":15.0,"spd":1.3},{"t":1794376795000,"lat":25.031052,"lon":121.501168,"acc":15.0,"spd":1.7},{"t":1794376800000,"lat":25.030926,"lon":121.50128,"acc":15.0,"spd":1.5},{"t":1794376805000,"lat":25.030879,"lon":121.501198,"acc":15.0,"spd":1.6},{"t":1794376810000,"lat":25.030939,"lon":121.501259,"acc":15.0,"spd":1.1},{"t":1794376815000,"lat":25.031007,"lon":121.50137,"acc":15.0,"spd":1.6},{"t":1794376820000,"lat":25.03094,"lon":121.501469,"acc":15.0,"spd":1.4},{"t":1794376825000,"lat":25.030929,"lon":121.501489,"acc":15.0,"spd":1.3},{"t":1794376830000,"lat":25.030851,"lon":121.5016,"acc":15.0,"spd":1.3},{"t":1794376835000,"lat":25.030916,"lon":121.501652,"acc":15.0,"spd":1.6},{"t":1794376840000,"lat":25.030975,"lon":121.501809,"acc":15.0,"spd":1.5},{"t":1794376845000,"lat":25.031234,"lon":121.501743,"acc":15.0,"spd":1.2},{"t":1794376850000,"lat":25.031026,"lon":121.50187,"acc":15.0,"spd":1.5},{"t":1794376855000,"lat":25.031071,"lon":121.501981,"acc":15.0,"spd":1.2},{"t":1794376860000,"lat":25.030959,"lon":121.501929,"acc":15.0,"spd":1.5},{"t":1794376865000,"lat":25.030916,"lon":121.502256,"acc":15.0,"spd":1.4},{"t":1794376870000,"lat":25.031267,"lon":121.501947,"acc":15.0,"spd":1.3},{"t":1794376875000,"lat":25.030906,"lon":121.502306,"acc":15.0,"spd":1.6},{"t":1794376880000,"lat":25.031221,"lon":121.502166,"acc":15.0,"spd":1.6},{"t":1794376885000,"lat":25.030904,"lon":121.502472,"acc":15.0,"spd":1.7},{"t":1794376890000,"lat":25.030978,"lon":121.502467,"acc":15.0,"spd":1.2},{"t":1794376895000,"lat":25.031251,"lon":121.502418,"acc":15.0,"spd":1.4},{"t":1794376900000,"lat":25.03117,"lon":121.502578,"acc":15.0,"spd":1.6},{"t":1794376905000,"lat":25.031055,"lon":121.502476,"acc":15.0,"spd":1.2},{"t":1794376910000,"lat":25.031252,"lon":121.502658,"acc":15.0,"spd":1.1},{"t":1794376915000,"lat":25.031265,"lon":121.502467,"acc":15.0,"spd":1.7},{"t":1794376920000,"lat":25.031158,"lon":121.502774,"acc":15.0,"spd":1.4},{"t":1794376925000,"lat":25.031178,"lon":121.50295,"acc":15.0,"spd":1.6},{"t":1794376930000,"lat":25.031157,"lon":121.502887,"acc":15.0,"spd":1.5},{"t":1794376935000,"lat":25.031369,"lon":121.503063,"acc":15.0,"spd":1.5},{"t":1794376940000,"lat":25.031026,"lon":121.502861,"acc":15.0,"spd":1.4},{"t":1794376945000,"lat":25.03127,"lon":121.503156,"acc":15.0,"spd":1.5},{"t":1794376950000,"lat":25.031546,"lon":121.503058,"acc":15.0,"spd":1.1},{"t":1794376955000,"lat":25.031498,"lon":121.503147,"acc":15.0,"spd":1.4},{"t":1794376960000,"lat":25.03155,"lon":121.503333,"acc":15.0,"spd":1.1},{"t":1794376965000,"lat":25.031483,"lon":121.503288,"acc":15.0,"spd":1.7},{"t":1794376970000,"lat":25.031604,"lon":121.503415,"acc":15.0,"spd":1.2},{"t":1794376975000,"lat":25.031576,"lon":121.503445,"acc":15.0,"spd":1.6},{"t":1794376980000,"lat":25.031552,"lon":121.503437,"acc":15.0,"spd":1.2},{"t":1794376985000,"lat":25.031676,"lon":121.503413,"acc":15.0,"spd":1.5},{"t":1794376990000,"lat":25.031751,"lon":121.503532,"acc":15.0,"spd":1.4},{"t":1794376995000,"lat":25.03179,"lon":121.503505,"acc":15.0,"spd":1.1},{"t":1794377000000,"lat":25.031873,"lon":121.5036,"acc":15.0,"spd":1.6},{"t":1794377005000,"lat":25.031844,"lon":121.503651,"acc":15.0,"spd":1.3},{"t":1794377010000,"lat":25.031824,"lon":121.503914,"acc":15.0,"spd":1.4},{"t":1794377015000,"lat":25.032159,"lon":121.503907,"acc":15.0,"spd":1.5},{"t":1794377020000,"lat":25.031867,"lon":121.503945,"acc":15.0,"spd":1.2},{"t":1794377025000,"lat":25.032035,"lon":121.5039,"acc":15.0,"spd":1.2},{"t":1794377030000,"lat":25.031901,"lon":121.503874,"acc":15.0,"spd":1.8},{"t":1794377035000,"lat":25.031844,"lon":121.504102,"acc":15.0,"spd":1.6},{"t":1794377040000,"lat":25.031957,"lon":121.503856,"acc":15.0,"spd":1.5},{"t":1794377045000,"lat":25.032205,"lon":121.504079,"acc":15.0,"spd":1.5},{"t":1794377050000,"lat":25.032076,"lon":121.504375,"acc":15.0,"spd":1.3},{"t":1794377055000,"lat":25.032106,"lon":121.504156,"acc":15.0,"spd":1.7},{"t":1794377060000,"lat":25.032302,"lon":121.504313,"acc":15.0,"spd":1.6},{"t":1794377065000,"lat":25.032259,"lon":121.504527,"acc":15.0,"spd":1.3},{"t":1794377070000,"lat":25.032316,"lon":121.504155,"acc":15.0,"spd":1.8},{"t":1794377075000,"lat":25.032264,"lon":121.504471,"acc":15.0,"spd":1.7},{"t":1794377080000,"lat":25.032574,"lon":121.504578,"acc":15.0,"spd":1.1},{"t":1794377085000,"lat":25.032398,"lon":121.504513,"acc":15.0,"spd":1.4},{"t":1794377090000,"lat":25.032638,"lon":121.504742,"acc":15.0,"spd":1.1},{"t":1794377095000,"lat":25.032417,"lon":121.504668,"acc":15.0,"spd":1.0},{"t":1794377100000,"lat":25.032619,"lon":121.504882,"acc":15.0,"spd":1.3},{"t":1794377105000,"lat":25.032552,"lon":121.504887,"acc":15.0,"spd":1.2},{"t":1794377110000,"lat":25.032628,"lon":121.504839,"acc":15.0,"spd":1.4},{"t":1794377115000,"lat":25.032725,"lon":121.504865,"acc":15.0,"spd":1.5},{"t":1794377120000,"lat":25.032636,"lon":121.504779,"acc":15.0,"spd":1.3},{"t":1794377125000,"lat":25.03261,"lon":121.505255,"acc":15.0,"spd":1.2},{"t":1794377130000,"lat":25.0327,"lon":121.505232,"acc":15.0,"spd":1.2},{"t":1794377135000,"lat":25.03296,"lon":121.505086,"acc":15.0,"spd":1.4},{"t":1794377140000,"lat":25.03308,"lon":121.50532,"acc":15.0,"spd":1.3},{"t":1794377145000,"lat":25.033147,"lon":121.505343,"acc":15.0,"spd":1.4},{"t":1794377150000,"lat":25.033222,"lon":121.505245,"acc":15.0,"spd":1.3},{"t":1794377155000,"lat":25.033213,"lon":121.505392,"acc":15.0,"spd":1.4},{"t":1794377160000,"lat":25.033148,"lon":121.505094,"acc":15.0,"spd":1.1},{"t":1794377165000,"lat":25.033288,"lon":121.505079,"acc":15.0,"spd":1.4},{"t":1794377170000,"lat":25.033265,"lon":121.505457,"acc":15.0,"spd":1.4},{"t":1794377175000,"lat":25.033327,"lon":121.505307,"acc":15.0,"spd":1.6},{"t":1794377180000,"lat":25.033478,"lon":121.50527,"acc":15.0,"spd":1.4},{"t":1794377185000,"lat":25.033567,"lon":121.50536,"acc":15.0,"spd":1.5},{"t":1794377190000,"lat":25.033477,"lon":121.505551,"acc":15.0,"spd":1.5},{"t":1794377195000,"lat":25.033602,"lon":121.505224,"acc":15.0,"spd":1.1},{"t":1794377200000,"lat":25.033782,"lon":121.505546,"acc":15.0,"spd":1.5},{"t":1794377205000,"lat":25.033885,"lon":121.505594,"acc":15.0,"spd":1.4},{"t":1794377210000,"lat":25.033755,"lon":121.505592,"acc":15.0,"spd":1.7},{"t":1794377215000,"lat":25.033574,"lon":121.505548,"acc":15.0,"spd":1.5},{"t":1794377220000,"lat":25.033888,"lon":121.505586,"acc":15.0,"spd":1.6},{"t":1794377225000,"lat":25.033774,"lon":121.505588,"acc":15.0,"spd":1.4},{"t":1794377230000,"lat":25.034021,"lon":121.505696,"acc":15.0,"spd":1.1},{"t":1794377235000,"lat":25.034016,"lon":121.505689,"acc":15.0,"spd":1.3},{"t":1794377240000,"lat":25.034014,"lon":121.505822,"acc":15.0,"spd":1.3},{"t":1794377245000,"lat":25.034018,"lon":121.505936,"acc":15.0,"spd":1.3},{"t":1794377250000,"lat":25.034173,"lon":121.505617,"acc":15.0,"spd":1.3},{"t":1794377255000,"lat":25.034187,"lon":121.505781,"acc":15.0,"spd":1.4},{"t":1794377260000,"lat":25.034271,"lon":121.505895,"acc":15.0,"spd":1.1},{"t":1794377265000,"lat":25.03441,"lon":121.5059,"acc":15.0,"spd":1.3},{"t":1794377270000,"lat":25.034527,"lon":121.506016,"acc":15.0,"spd":1.5},{"t":1794377275000,"lat":25.034444,"lon":121.506046,"acc":15.0,"spd":1.7},{"t":1794377280000,"lat":25.034448,"lon":121.506069,"acc":15.0,"spd":0.9},{"t":1794377285000,"lat":25.034599,"lon":121.506118,"acc":15.0,"spd":1.5},{"t":1794377290000,"lat":25.034536,"lon":121.506112,"acc":15.0,"spd":1.1},{"t":1794377295000,"lat":25.034739,"lon":121.506048,"acc":15.0,"spd":1.4},{"t":1794377300000,"lat":25.035077,"lon":121.506157,"acc":15.0,"spd":1.2},{"t":1794377305000,"lat":25.034656,"lon":121.506073,"acc":15.0,"spd":1.2},{"t":1794377310000,"lat":25.034948,"lon":121.506287,"acc":15.0,"spd":1.5},{"t":1794377315000,"lat":25.034941,"lon":121.506134,"acc":15.0,"spd":1.4},{"t":1794377320000,"lat":25.035119,"lon":121.506378,"acc":15.0,"spd":1.4},{"t":1794377325000,"lat":25.035155,"lon":121.506307,"acc":15.0,"spd":1.3},{"t":1794377330000,"lat":25.035019,"lon":121.506129,"acc":15.0,"spd":1.4},{"t":1794377335000,"lat":25.035235,"lon":121.506366,"acc":15.0,"spd":1.2},{"t":1794377340000,"lat":25.035157,"lon":121.506441,"acc":15.0,"spd":1.4},{"t":1794377345000,"lat":25.035259,"lon":121.506434,"acc":15.0,"spd":1.4},{"t":1794377350000,"lat":25.035511,"lon":121.506426,"acc":15.0,"spd":1.2},{"t":1794377355000,"lat":25.035466,"lon":121.506428,"acc":15.0,"spd":2.1},{"t":1794377360000,"lat":25.035361,"lon":121.506561,"acc":15.0,"spd":1.2},{"t":1794377365000,"lat":25.035295,"lon":121.506511,"acc":15.0,"spd":1.5},{"t":1794377370000,"lat":25.035472,"lon":121.50645,"acc":15.0,"spd":1.3},{"t":1794377375000,"lat":25.035546,"lon":121.506466,"acc":15.0,"spd":1.0},{"t":1794377380000,"lat":25.035638,"lon":121.506684,"acc":15.0,"spd":1.2},{"t":1794377385000,"lat":25.035813,"lon":121.506488,"acc":15.0,"spd":1.6},{"t":1794377390000,"lat":25.035589,"lon":121.50661,"acc":15.0,"spd":1.4},{"t":1794377395000,"lat":25.035872,"lon":121.506747,"acc":15.0,"spd":1.4},{"t":1794377400000,"lat":25.035718,"lon":121.506817,"acc":15.0,"spd":1.3},{"t":1794377405000,"lat":25.035817,"lon":121.506564,"acc":15.0,"spd":1.7}]} \ No newline at end of file diff --git a/android/app/src/test/resources/ride_replay_TXG300_0.json b/android/app/src/test/resources/ride_replay_TXG300_0.json new file mode 100644 index 0000000..bcda3e3 --- /dev/null +++ b/android/app/src/test/resources/ride_replay_TXG300_0.json @@ -0,0 +1 @@ +{"route":"TXG300","pathId":0,"plate":"EAL-2225","boardedAtMs":1794376374000,"boardingStopIdRaw":"19437","destinationStopIdRaw":"696","stops":[{"stopIdRaw":"13567","sequence":1,"lat":24.225899,"lon":120.576539},{"stopIdRaw":"19437","sequence":2,"lat":24.221858,"lon":120.579074},{"stopIdRaw":"19435","sequence":3,"lat":24.217481,"lon":120.580258},{"stopIdRaw":"19433","sequence":4,"lat":24.209925,"lon":120.581137},{"stopIdRaw":"11257","sequence":5,"lat":24.186045538196,"lon":120.58566809429},{"stopIdRaw":"16564","sequence":6,"lat":24.182739398451,"lon":120.59351635159},{"stopIdRaw":"696","sequence":7,"lat":24.182835,"lon":120.603479},{"stopIdRaw":"18163","sequence":8,"lat":24.184402,"lon":120.60992},{"stopIdRaw":"10967","sequence":9,"lat":24.182245,"lon":120.616774},{"stopIdRaw":"10730","sequence":10,"lat":24.180888184761,"lon":120.61955176882},{"stopIdRaw":"9993","sequence":11,"lat":24.178688,"lon":120.623955},{"stopIdRaw":"1311","sequence":12,"lat":24.169165,"lon":120.638879},{"stopIdRaw":"1309","sequence":13,"lat":24.165999,"lon":120.643231},{"stopIdRaw":"8503","sequence":14,"lat":24.162766,"lon":120.649195},{"stopIdRaw":"19431","sequence":15,"lat":24.159138,"lon":120.655721},{"stopIdRaw":"19430","sequence":16,"lat":24.157273797065,"lon":120.65910595273},{"stopIdRaw":"6053","sequence":17,"lat":24.155302,"lon":120.662745},{"stopIdRaw":"10724","sequence":18,"lat":24.152634633083,"lon":120.66746262747},{"stopIdRaw":"1301","sequence":19,"lat":24.149206,"lon":120.671613},{"stopIdRaw":"19264","sequence":20,"lat":24.146362,"lon":120.674826},{"stopIdRaw":"19265","sequence":21,"lat":24.144629,"lon":120.676875},{"stopIdRaw":"5386","sequence":22,"lat":24.143504,"lon":120.678188},{"stopIdRaw":"13530","sequence":23,"lat":24.141863,"lon":120.680019},{"stopIdRaw":"2381","sequence":24,"lat":24.139861,"lon":120.682304},{"stopIdRaw":"22398","sequence":25,"lat":24.138804,"lon":120.683527},{"stopIdRaw":"12251","sequence":26,"lat":24.137749,"lon":120.686459}],"busReports":[{"id":"757-U8","dir":0,"lat":24.261023,"lon":120.521953,"t":1794376111000,"rx":1794376155992},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522088,"t":1794376128000,"rx":1794376155992},{"id":"763-U8","dir":0,"lat":24.150223,"lon":120.670439,"t":1794376124000,"rx":1794376155992},{"id":"780-U8","dir":1,"lat":24.24042,"lon":120.564173,"t":1794376116000,"rx":1794376155992},{"id":"EAL-1378","dir":0,"lat":24.182928,"lon":120.603616,"t":1794376122000,"rx":1794376155992},{"id":"EAL-1589","dir":0,"lat":24.275313,"lon":120.547246,"t":1794376112000,"rx":1794376155992},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564141,"t":1794376129000,"rx":1794376155992},{"id":"EAL-2225","dir":0,"lat":24.231181,"lon":120.573191,"t":1794376114000,"rx":1794376155992},{"id":"EAL-2263","dir":1,"lat":24.179173,"lon":120.623493,"t":1794376126000,"rx":1794376155992},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376112000,"rx":1794376155992},{"id":"EAL-2269","dir":1,"lat":24.244975,"lon":120.573271,"t":1794376125000,"rx":1794376155992},{"id":"EAL-5358","dir":1,"lat":24.143899,"lon":120.677993,"t":1794376130000,"rx":1794376155992},{"id":"EAL-5365","dir":1,"lat":24.213966,"lon":120.581001,"t":1794376128000,"rx":1794376155992},{"id":"EAL-5390","dir":1,"lat":24.178801,"lon":120.624426,"t":1794376124000,"rx":1794376155992},{"id":"EAL-6027","dir":0,"lat":24.264823,"lon":120.54102,"t":1794376122000,"rx":1794376155992},{"id":"EAL-6028","dir":1,"lat":24.143521,"lon":120.678668,"t":1794376128000,"rx":1794376155992},{"id":"EAL-6029","dir":1,"lat":24.182651,"lon":120.601461,"t":1794376118000,"rx":1794376155992},{"id":"EAR-1003","dir":0,"lat":24.17912,"lon":120.623225,"t":1794376123000,"rx":1794376155992},{"id":"EAR-1015","dir":1,"lat":24.183343,"lon":120.592771,"t":1794376121000,"rx":1794376155992},{"id":"EAR-1017","dir":0,"lat":24.159829,"lon":120.654406,"t":1794376129000,"rx":1794376155992},{"id":"FAE-800","dir":0,"lat":24.241038,"lon":120.564229,"t":1794376122000,"rx":1794376155992},{"id":"757-U8","dir":0,"lat":24.261026,"lon":120.521974,"t":1794376151000,"rx":1794376179410},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522104,"t":1794376148000,"rx":1794376179410},{"id":"763-U8","dir":0,"lat":24.149229,"lon":120.671613,"t":1794376144000,"rx":1794376179410},{"id":"780-U8","dir":1,"lat":24.240456,"lon":120.564253,"t":1794376156000,"rx":1794376179410},{"id":"EAL-1378","dir":0,"lat":24.18346,"lon":120.605855,"t":1794376154000,"rx":1794376179410},{"id":"EAL-1589","dir":0,"lat":24.275313,"lon":120.547246,"t":1794376152000,"rx":1794376179410},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564143,"t":1794376149000,"rx":1794376179410},{"id":"EAL-2225","dir":0,"lat":24.229793,"lon":120.574021,"t":1794376154000,"rx":1794376179410},{"id":"EAL-2263","dir":1,"lat":24.179651,"lon":120.622573,"t":1794376150000,"rx":1794376179410},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376152000,"rx":1794376179410},{"id":"EAL-2269","dir":1,"lat":24.247418,"lon":120.574936,"t":1794376145000,"rx":1794376179410},{"id":"EAL-5358","dir":1,"lat":24.143914,"lon":120.677999,"t":1794376155000,"rx":1794376179410},{"id":"EAL-5365","dir":1,"lat":24.215949,"lon":120.580738,"t":1794376144000,"rx":1794376179410},{"id":"EAL-5390","dir":1,"lat":24.179273,"lon":120.623526,"t":1794376144000,"rx":1794376179410},{"id":"EAL-6027","dir":0,"lat":24.264833,"lon":120.541016,"t":1794376142000,"rx":1794376179410},{"id":"EAL-6028","dir":1,"lat":24.143853,"lon":120.678298,"t":1794376157000,"rx":1794376179410},{"id":"EAL-6029","dir":1,"lat":24.182303,"lon":120.59596,"t":1794376158000,"rx":1794376179410},{"id":"EAR-1003","dir":0,"lat":24.178871,"lon":120.623828,"t":1794376160000,"rx":1794376179410},{"id":"EAR-1015","dir":1,"lat":24.185193,"lon":120.588419,"t":1794376159000,"rx":1794376179410},{"id":"EAR-1017","dir":0,"lat":24.159251,"lon":120.655535,"t":1794376149000,"rx":1794376179410},{"id":"FAE-800","dir":0,"lat":24.241023,"lon":120.564229,"t":1794376142000,"rx":1794376179410},{"id":"757-U8","dir":0,"lat":24.261026,"lon":120.521958,"t":1794376171000,"rx":1794376203395},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522088,"t":1794376168000,"rx":1794376203395},{"id":"763-U8","dir":0,"lat":24.149176,"lon":120.671666,"t":1794376164000,"rx":1794376203395},{"id":"EAL-1378","dir":0,"lat":24.183696,"lon":120.606886,"t":1794376162000,"rx":1794376203395},{"id":"EAL-1589","dir":0,"lat":24.275878,"lon":120.547798,"t":1794376172000,"rx":1794376203395},{"id":"EAL-2032","dir":0,"lat":24.240143,"lon":120.564143,"t":1794376169000,"rx":1794376203395},{"id":"EAL-2225","dir":0,"lat":24.22858,"lon":120.574791,"t":1794376174000,"rx":1794376203395},{"id":"EAL-2263","dir":1,"lat":24.179878,"lon":120.622141,"t":1794376166000,"rx":1794376203395},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376172000,"rx":1794376203395},{"id":"EAL-2269","dir":1,"lat":24.24818,"lon":120.575533,"t":1794376165000,"rx":1794376203395},{"id":"EAL-5365","dir":1,"lat":24.216768,"lon":120.580629,"t":1794376164000,"rx":1794376203395},{"id":"EAL-5390","dir":1,"lat":24.179823,"lon":120.622528,"t":1794376173000,"rx":1794376203395},{"id":"EAL-6027","dir":0,"lat":24.264833,"lon":120.541018,"t":1794376162000,"rx":1794376203395},{"id":"EAL-6029","dir":1,"lat":24.182826,"lon":120.5939,"t":1794376175000,"rx":1794376203395},{"id":"EAR-1015","dir":1,"lat":24.185781,"lon":120.587029,"t":1794376171000,"rx":1794376203395},{"id":"EAR-1017","dir":0,"lat":24.159275,"lon":120.655581,"t":1794376169000,"rx":1794376203395},{"id":"FAE-800","dir":0,"lat":24.241018,"lon":120.564228,"t":1794376162000,"rx":1794376203395},{"id":"757-U8","dir":0,"lat":24.261026,"lon":120.521974,"t":1794376191000,"rx":1794376227353},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522104,"t":1794376188000,"rx":1794376227353},{"id":"763-U8","dir":0,"lat":24.147903,"lon":120.673199,"t":1794376204000,"rx":1794376227353},{"id":"780-U8","dir":1,"lat":24.240443,"lon":120.564239,"t":1794376196000,"rx":1794376227353},{"id":"EAL-1378","dir":0,"lat":24.184556,"lon":120.61036,"t":1794376202000,"rx":1794376227353},{"id":"EAL-1589","dir":0,"lat":24.276393,"lon":120.549271,"t":1794376192000,"rx":1794376227353},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564143,"t":1794376189000,"rx":1794376227353},{"id":"EAL-2225","dir":0,"lat":24.227433,"lon":120.57555,"t":1794376194000,"rx":1794376227353},{"id":"EAL-2263","dir":1,"lat":24.17996,"lon":120.621999,"t":1794376186000,"rx":1794376227353},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376192000,"rx":1794376227353},{"id":"EAL-2269","dir":1,"lat":24.247933,"lon":120.575059,"t":1794376205000,"rx":1794376227353},{"id":"EAL-5358","dir":1,"lat":24.143913,"lon":120.678004,"t":1794376195000,"rx":1794376227353},{"id":"EAL-5365","dir":1,"lat":24.219484,"lon":120.580311,"t":1794376204000,"rx":1794376227353},{"id":"EAL-5390","dir":1,"lat":24.179938,"lon":120.62227,"t":1794376204000,"rx":1794376227353},{"id":"EAL-6027","dir":0,"lat":24.264826,"lon":120.541023,"t":1794376202000,"rx":1794376227353},{"id":"EAL-6028","dir":1,"lat":24.143943,"lon":120.678196,"t":1794376204000,"rx":1794376227353},{"id":"EAL-6029","dir":1,"lat":24.182976,"lon":120.593533,"t":1794376198000,"rx":1794376227353},{"id":"EAR-1003","dir":0,"lat":24.17884,"lon":120.623841,"t":1794376200000,"rx":1794376227353},{"id":"EAR-1015","dir":1,"lat":24.186153,"lon":120.586065,"t":1794376199000,"rx":1794376227353},{"id":"EAR-1017","dir":0,"lat":24.159146,"lon":120.655726,"t":1794376189000,"rx":1794376227353},{"id":"FAE-800","dir":0,"lat":24.241003,"lon":120.564228,"t":1794376202000,"rx":1794376227353},{"id":"757-U8","dir":0,"lat":24.261026,"lon":120.521974,"t":1794376231000,"rx":1794376251618},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522104,"t":1794376228000,"rx":1794376251618},{"id":"763-U8","dir":0,"lat":24.147813,"lon":120.673306,"t":1794376224000,"rx":1794376251618},{"id":"780-U8","dir":1,"lat":24.240426,"lon":120.564239,"t":1794376216000,"rx":1794376251618},{"id":"EAL-1378","dir":0,"lat":24.184554,"lon":120.611403,"t":1794376230000,"rx":1794376251618},{"id":"EAL-1589","dir":0,"lat":24.276388,"lon":120.549284,"t":1794376232000,"rx":1794376251618},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564161,"t":1794376229000,"rx":1794376251618},{"id":"EAL-2225","dir":0,"lat":24.227276,"lon":120.575654,"t":1794376234000,"rx":1794376251618},{"id":"EAL-2263","dir":1,"lat":24.180138,"lon":120.621594,"t":1794376229000,"rx":1794376251618},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376232000,"rx":1794376251618},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575059,"t":1794376225000,"rx":1794376251618},{"id":"EAL-5358","dir":1,"lat":24.144576,"lon":120.677153,"t":1794376215000,"rx":1794376251618},{"id":"EAL-5365","dir":1,"lat":24.2195,"lon":120.580303,"t":1794376224000,"rx":1794376251618},{"id":"EAL-5390","dir":1,"lat":24.180201,"lon":120.621656,"t":1794376235000,"rx":1794376251618},{"id":"EAL-6027","dir":0,"lat":24.264828,"lon":120.541006,"t":1794376222000,"rx":1794376251618},{"id":"EAL-6028","dir":1,"lat":24.144916,"lon":120.676971,"t":1794376231000,"rx":1794376251618},{"id":"EAL-6029","dir":1,"lat":24.183001,"lon":120.593441,"t":1794376218000,"rx":1794376251618},{"id":"EAR-1003","dir":0,"lat":24.178331,"lon":120.624713,"t":1794376220000,"rx":1794376251618},{"id":"EAR-1015","dir":1,"lat":24.186238,"lon":120.585925,"t":1794376219000,"rx":1794376251618},{"id":"EAR-1017","dir":0,"lat":24.159143,"lon":120.655738,"t":1794376229000,"rx":1794376251618},{"id":"FAE-800","dir":0,"lat":24.241001,"lon":120.564226,"t":1794376222000,"rx":1794376251618},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521956,"t":1794376351000,"rx":1794376395767},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522088,"t":1794376368000,"rx":1794376395767},{"id":"763-U8","dir":0,"lat":24.145483,"lon":120.675946,"t":1794376364000,"rx":1794376395767},{"id":"780-U8","dir":1,"lat":24.240379,"lon":120.56416,"t":1794376356000,"rx":1794376395767},{"id":"EAL-1378","dir":0,"lat":24.181228,"lon":120.618816,"t":1794376362000,"rx":1794376395767},{"id":"EAL-1589","dir":0,"lat":24.272808,"lon":120.559848,"t":1794376352000,"rx":1794376395767},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564183,"t":1794376369000,"rx":1794376395767},{"id":"EAL-2225","dir":0,"lat":24.222293,"lon":120.578826,"t":1794376354000,"rx":1794376395767},{"id":"EAL-2263","dir":1,"lat":24.182625,"lon":120.616613,"t":1794376366000,"rx":1794376395767},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376352000,"rx":1794376395767},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575055,"t":1794376365000,"rx":1794376395767},{"id":"EAL-5358","dir":1,"lat":24.150656,"lon":120.670243,"t":1794376355000,"rx":1794376395767},{"id":"EAL-5365","dir":1,"lat":24.22512,"lon":120.577335,"t":1794376364000,"rx":1794376395767},{"id":"EAL-5390","dir":1,"lat":24.182533,"lon":120.616641,"t":1794376364000,"rx":1794376395767},{"id":"EAL-6027","dir":0,"lat":24.264814,"lon":120.540994,"t":1794376362000,"rx":1794376395767},{"id":"EAL-6028","dir":1,"lat":24.150594,"lon":120.67058,"t":1794376357000,"rx":1794376395767},{"id":"EAL-6029","dir":1,"lat":24.186063,"lon":120.586218,"t":1794376361000,"rx":1794376395767},{"id":"EAR-1003","dir":0,"lat":24.169301,"lon":120.638728,"t":1794376360000,"rx":1794376395767},{"id":"EAR-1015","dir":1,"lat":24.197561,"lon":120.580528,"t":1794376369000,"rx":1794376395767},{"id":"EAR-1017","dir":0,"lat":24.155075,"lon":120.663313,"t":1794376369000,"rx":1794376395767},{"id":"FAE-800","dir":0,"lat":24.240923,"lon":120.56425,"t":1794376362000,"rx":1794376395767},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521973,"t":1794376371000,"rx":1794376407860},{"id":"763-U8","dir":0,"lat":24.144686,"lon":120.676826,"t":1794376384000,"rx":1794376407860},{"id":"780-U8","dir":1,"lat":24.240383,"lon":120.56416,"t":1794376376000,"rx":1794376407860},{"id":"EAL-1378","dir":0,"lat":24.181228,"lon":120.618816,"t":1794376382000,"rx":1794376407860},{"id":"EAL-1589","dir":0,"lat":24.272196,"lon":120.561591,"t":1794376372000,"rx":1794376407860},{"id":"EAL-2225","dir":0,"lat":24.221758,"lon":120.579133,"t":1794376374000,"rx":1794376407860},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376372000,"rx":1794376407860},{"id":"EAL-2269","dir":1,"lat":24.247924,"lon":120.575055,"t":1794376385000,"rx":1794376407860},{"id":"EAL-5358","dir":1,"lat":24.150655,"lon":120.670266,"t":1794376375000,"rx":1794376407860},{"id":"EAL-5365","dir":1,"lat":24.225238,"lon":120.577291,"t":1794376384000,"rx":1794376407860},{"id":"EAL-5390","dir":1,"lat":24.182533,"lon":120.616638,"t":1794376384000,"rx":1794376407860},{"id":"EAL-6027","dir":0,"lat":24.264816,"lon":120.540998,"t":1794376382000,"rx":1794376407860},{"id":"EAL-6028","dir":1,"lat":24.150653,"lon":120.670528,"t":1794376377000,"rx":1794376407860},{"id":"EAL-6029","dir":1,"lat":24.186144,"lon":120.58606,"t":1794376378000,"rx":1794376407860},{"id":"EAR-1003","dir":0,"lat":24.169344,"lon":120.638819,"t":1794376380000,"rx":1794376407860},{"id":"EAR-1015","dir":1,"lat":24.199161,"lon":120.580714,"t":1794376379000,"rx":1794376407860},{"id":"FAE-800","dir":0,"lat":24.240905,"lon":120.564228,"t":1794376382000,"rx":1794376407860},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521973,"t":1794376411000,"rx":1794376431626},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522088,"t":1794376408000,"rx":1794376431626},{"id":"763-U8","dir":0,"lat":24.143623,"lon":120.678053,"t":1794376404000,"rx":1794376431626},{"id":"780-U8","dir":1,"lat":24.24039,"lon":120.56416,"t":1794376396000,"rx":1794376431626},{"id":"EAL-1378","dir":0,"lat":24.180976,"lon":120.619408,"t":1794376402000,"rx":1794376431626},{"id":"EAL-1589","dir":0,"lat":24.272198,"lon":120.561631,"t":1794376412000,"rx":1794376431626},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564184,"t":1794376409000,"rx":1794376431626},{"id":"EAL-2225","dir":0,"lat":24.22171,"lon":120.579241,"t":1794376414000,"rx":1794376431626},{"id":"EAL-2263","dir":1,"lat":24.184123,"lon":120.613479,"t":1794376413000,"rx":1794376431626},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376412000,"rx":1794376431626},{"id":"EAL-2269","dir":1,"lat":24.247926,"lon":120.575055,"t":1794376405000,"rx":1794376431626},{"id":"EAL-5358","dir":1,"lat":24.150656,"lon":120.670266,"t":1794376395000,"rx":1794376431626},{"id":"EAL-5365","dir":1,"lat":24.225394,"lon":120.577146,"t":1794376405000,"rx":1794376431626},{"id":"EAL-5390","dir":1,"lat":24.183463,"lon":120.615003,"t":1794376409000,"rx":1794376431626},{"id":"EAL-6027","dir":0,"lat":24.264818,"lon":120.541003,"t":1794376402000,"rx":1794376431626},{"id":"EAL-6028","dir":1,"lat":24.150653,"lon":120.670524,"t":1794376397000,"rx":1794376431626},{"id":"EAL-6029","dir":1,"lat":24.186208,"lon":120.586029,"t":1794376398000,"rx":1794376431626},{"id":"EAR-1003","dir":0,"lat":24.169318,"lon":120.638828,"t":1794376400000,"rx":1794376431626},{"id":"EAR-1015","dir":1,"lat":24.201438,"lon":120.580961,"t":1794376399000,"rx":1794376431626},{"id":"EAR-1017","dir":0,"lat":24.154926,"lon":120.663621,"t":1794376411000,"rx":1794376431626},{"id":"FAE-800","dir":0,"lat":24.240896,"lon":120.564224,"t":1794376402000,"rx":1794376431626},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522104,"t":1794376428000,"rx":1794376455876},{"id":"763-U8","dir":0,"lat":24.14348,"lon":120.678306,"t":1794376424000,"rx":1794376455876},{"id":"780-U8","dir":1,"lat":24.240393,"lon":120.56416,"t":1794376416000,"rx":1794376455876},{"id":"EAL-1378","dir":0,"lat":24.180958,"lon":120.619429,"t":1794376422000,"rx":1794376455876},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564168,"t":1794376429000,"rx":1794376455876},{"id":"EAL-2263","dir":1,"lat":24.184618,"lon":120.611975,"t":1794376426000,"rx":1794376455876},{"id":"EAL-2269","dir":1,"lat":24.247934,"lon":120.575059,"t":1794376425000,"rx":1794376455876},{"id":"EAL-5358","dir":1,"lat":24.150656,"lon":120.670265,"t":1794376415000,"rx":1794376455876},{"id":"EAL-5365","dir":1,"lat":24.225381,"lon":120.577151,"t":1794376424000,"rx":1794376455876},{"id":"EAL-5390","dir":1,"lat":24.184358,"lon":120.613068,"t":1794376424000,"rx":1794376455876},{"id":"EAL-6027","dir":0,"lat":24.264821,"lon":120.541008,"t":1794376422000,"rx":1794376455876},{"id":"EAL-6028","dir":1,"lat":24.150653,"lon":120.670541,"t":1794376417000,"rx":1794376455876},{"id":"EAL-6029","dir":1,"lat":24.186215,"lon":120.586029,"t":1794376418000,"rx":1794376455876},{"id":"EAR-1003","dir":0,"lat":24.169319,"lon":120.638828,"t":1794376420000,"rx":1794376455876},{"id":"EAR-1015","dir":1,"lat":24.201461,"lon":120.580956,"t":1794376419000,"rx":1794376455876},{"id":"EAR-1017","dir":0,"lat":24.154811,"lon":120.663701,"t":1794376429000,"rx":1794376455876},{"id":"FAE-800","dir":0,"lat":24.240893,"lon":120.564223,"t":1794376422000,"rx":1794376455876},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521973,"t":1794376451000,"rx":1794376479380},{"id":"762-U8","dir":0,"lat":24.259168,"lon":120.522088,"t":1794376448000,"rx":1794376479380},{"id":"763-U8","dir":0,"lat":24.14309,"lon":120.678773,"t":1794376444000,"rx":1794376479380},{"id":"780-U8","dir":0,"lat":24.240383,"lon":120.56416,"t":1794376456000,"rx":1794376479380},{"id":"782-U8","dir":0,"lat":24.349775,"lon":120.616411,"t":1794376460000,"rx":1794376479380},{"id":"EAL-1378","dir":0,"lat":24.179688,"lon":120.622026,"t":1794376459000,"rx":1794376479380},{"id":"EAL-1589","dir":0,"lat":24.27146,"lon":120.563603,"t":1794376452000,"rx":1794376479380},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564184,"t":1794376449000,"rx":1794376479380},{"id":"EAL-2225","dir":0,"lat":24.221121,"lon":120.579503,"t":1794376454000,"rx":1794376479380},{"id":"EAL-2263","dir":1,"lat":24.184666,"lon":120.610126,"t":1794376446000,"rx":1794376479380},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576544,"t":1794376452000,"rx":1794376479380},{"id":"EAL-2269","dir":1,"lat":24.247939,"lon":120.575061,"t":1794376445000,"rx":1794376479380},{"id":"EAL-5358","dir":1,"lat":24.150656,"lon":120.670259,"t":1794376455000,"rx":1794376479380},{"id":"EAL-5365","dir":0,"lat":24.226216,"lon":120.576625,"t":1794376457000,"rx":1794376479380},{"id":"EAL-5390","dir":1,"lat":24.184744,"lon":120.610434,"t":1794376444000,"rx":1794376479380},{"id":"EAL-6027","dir":0,"lat":24.264823,"lon":120.541008,"t":1794376442000,"rx":1794376479380},{"id":"EAL-6028","dir":1,"lat":24.150655,"lon":120.670524,"t":1794376457000,"rx":1794376479380},{"id":"EAL-6029","dir":1,"lat":24.186251,"lon":120.585961,"t":1794376458000,"rx":1794376479380},{"id":"EAR-1003","dir":0,"lat":24.168103,"lon":120.64024,"t":1794376460000,"rx":1794376479380},{"id":"EAR-1015","dir":1,"lat":24.201461,"lon":120.580958,"t":1794376459000,"rx":1794376479380},{"id":"EAR-1017","dir":0,"lat":24.154569,"lon":120.664128,"t":1794376456000,"rx":1794376479380},{"id":"FAE-800","dir":0,"lat":24.240891,"lon":120.564223,"t":1794376442000,"rx":1794376479380},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521955,"t":1794376471000,"rx":1794376504105},{"id":"762-U8","dir":0,"lat":24.25916,"lon":120.522091,"t":1794376468000,"rx":1794376504105},{"id":"763-U8","dir":0,"lat":24.142949,"lon":120.67892,"t":1794376464000,"rx":1794376504105},{"id":"EAL-1378","dir":0,"lat":24.17892,"lon":120.623549,"t":1794376473000,"rx":1794376504105},{"id":"EAL-1589","dir":0,"lat":24.270624,"lon":120.565969,"t":1794376472000,"rx":1794376504105},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.56417,"t":1794376469000,"rx":1794376504105},{"id":"EAL-2225","dir":0,"lat":24.219183,"lon":120.580071,"t":1794376474000,"rx":1794376504105},{"id":"EAL-2263","dir":1,"lat":24.184438,"lon":120.609033,"t":1794376466000,"rx":1794376504105},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376472000,"rx":1794376504105},{"id":"EAL-2269","dir":1,"lat":24.247933,"lon":120.575063,"t":1794376465000,"rx":1794376504105},{"id":"EAL-5365","dir":0,"lat":24.226913,"lon":120.576205,"t":1794376464000,"rx":1794376504105},{"id":"EAL-5390","dir":1,"lat":24.184494,"lon":120.609129,"t":1794376468000,"rx":1794376504105},{"id":"EAL-6027","dir":0,"lat":24.264823,"lon":120.541025,"t":1794376462000,"rx":1794376504105},{"id":"EAL-6028","dir":1,"lat":24.150823,"lon":120.67032,"t":1794376470000,"rx":1794376504105},{"id":"EAL-6029","dir":1,"lat":24.186496,"lon":120.585313,"t":1794376466000,"rx":1794376504105},{"id":"EAR-1003","dir":0,"lat":24.167165,"lon":120.64154,"t":1794376472000,"rx":1794376504105},{"id":"EAR-1017","dir":0,"lat":24.153316,"lon":120.666353,"t":1794376473000,"rx":1794376504105},{"id":"FAE-800","dir":0,"lat":24.240891,"lon":120.564221,"t":1794376462000,"rx":1794376504105},{"id":"757-U8","dir":0,"lat":24.261023,"lon":120.521955,"t":1794376491000,"rx":1794376527389},{"id":"762-U8","dir":0,"lat":24.259151,"lon":120.522093,"t":1794376488000,"rx":1794376527389},{"id":"763-U8","dir":0,"lat":24.14283,"lon":120.679119,"t":1794376504000,"rx":1794376527389},{"id":"780-U8","dir":0,"lat":24.240376,"lon":120.56416,"t":1794376496000,"rx":1794376527389},{"id":"782-U8","dir":0,"lat":24.294138,"lon":120.588976,"t":1794376500000,"rx":1794376527389},{"id":"EAL-1378","dir":0,"lat":24.178689,"lon":120.624036,"t":1794376502000,"rx":1794376527389},{"id":"EAL-1589","dir":0,"lat":24.269716,"lon":120.568491,"t":1794376492000,"rx":1794376527389},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564186,"t":1794376489000,"rx":1794376527389},{"id":"EAL-2225","dir":0,"lat":24.217503,"lon":120.580303,"t":1794376494000,"rx":1794376527389},{"id":"EAL-2263","dir":1,"lat":24.18336,"lon":120.604391,"t":1794376499000,"rx":1794376527389},{"id":"EAL-2265","dir":1,"lat":24.226491,"lon":120.576546,"t":1794376492000,"rx":1794376527389},{"id":"EAL-2269","dir":1,"lat":24.247945,"lon":120.575081,"t":1794376505000,"rx":1794376527389},{"id":"EAL-5358","dir":1,"lat":24.152384,"lon":120.668253,"t":1794376495000,"rx":1794376527389},{"id":"EAL-5365","dir":0,"lat":24.231486,"lon":120.573165,"t":1794376504000,"rx":1794376527389},{"id":"EAL-5390","dir":1,"lat":24.183344,"lon":120.604311,"t":1794376504000,"rx":1794376527389},{"id":"EAL-6027","dir":0,"lat":24.264803,"lon":120.541013,"t":1794376502000,"rx":1794376527389},{"id":"EAL-6028","dir":1,"lat":24.152291,"lon":120.668514,"t":1794376498000,"rx":1794376527389},{"id":"EAL-6029","dir":1,"lat":24.188391,"lon":120.581031,"t":1794376498000,"rx":1794376527389},{"id":"EAR-1003","dir":0,"lat":24.16617,"lon":120.642993,"t":1794376500000,"rx":1794376527389},{"id":"EAR-1015","dir":1,"lat":24.205995,"lon":120.58149,"t":1794376499000,"rx":1794376527389},{"id":"EAR-1017","dir":0,"lat":24.152709,"lon":120.667346,"t":1794376489000,"rx":1794376527389},{"id":"FAE-800","dir":0,"lat":24.240886,"lon":120.564215,"t":1794376502000,"rx":1794376527389},{"id":"757-U8","dir":0,"lat":24.261023,"lon":120.521969,"t":1794376531000,"rx":1794376552110},{"id":"762-U8","dir":0,"lat":24.259151,"lon":120.522093,"t":1794376528000,"rx":1794376552110},{"id":"763-U8","dir":0,"lat":24.142749,"lon":120.679199,"t":1794376524000,"rx":1794376552110},{"id":"780-U8","dir":0,"lat":24.240376,"lon":120.56416,"t":1794376516000,"rx":1794376552110},{"id":"782-U8","dir":0,"lat":24.294138,"lon":120.588975,"t":1794376520000,"rx":1794376552110},{"id":"EAL-1378","dir":0,"lat":24.178648,"lon":120.62411,"t":1794376522000,"rx":1794376552110},{"id":"EAL-1589","dir":0,"lat":24.265689,"lon":120.571356,"t":1794376532000,"rx":1794376552110},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564186,"t":1794376529000,"rx":1794376552110},{"id":"EAL-2225","dir":0,"lat":24.214331,"lon":120.580719,"t":1794376534000,"rx":1794376552110},{"id":"EAL-2263","dir":1,"lat":24.183073,"lon":120.603333,"t":1794376526000,"rx":1794376552110},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376532000,"rx":1794376552110},{"id":"EAL-2269","dir":1,"lat":24.247934,"lon":120.575073,"t":1794376525000,"rx":1794376552110},{"id":"EAL-5358","dir":1,"lat":24.154765,"lon":120.664121,"t":1794376535000,"rx":1794376552110},{"id":"EAL-5365","dir":0,"lat":24.233564,"lon":120.571875,"t":1794376524000,"rx":1794376552110},{"id":"EAL-5390","dir":1,"lat":24.18315,"lon":120.603518,"t":1794376524000,"rx":1794376552110},{"id":"EAL-6027","dir":0,"lat":24.2648,"lon":120.541013,"t":1794376522000,"rx":1794376552110},{"id":"EAL-6028","dir":1,"lat":24.152923,"lon":120.667554,"t":1794376523000,"rx":1794376552110},{"id":"EAL-6029","dir":1,"lat":24.190881,"lon":120.5798,"t":1794376518000,"rx":1794376552110},{"id":"EAR-1003","dir":0,"lat":24.165559,"lon":120.643876,"t":1794376521000,"rx":1794376552110},{"id":"EAR-1015","dir":1,"lat":24.209834,"lon":120.581479,"t":1794376534000,"rx":1794376552110},{"id":"EAR-1017","dir":0,"lat":24.151291,"lon":120.66924,"t":1794376529000,"rx":1794376552110},{"id":"FAE-800","dir":0,"lat":24.240884,"lon":120.564215,"t":1794376522000,"rx":1794376552110},{"id":"762-U8","dir":0,"lat":24.259151,"lon":120.522091,"t":1794376548000,"rx":1794376575379},{"id":"763-U8","dir":0,"lat":24.142536,"lon":120.679413,"t":1794376544000,"rx":1794376575379},{"id":"780-U8","dir":0,"lat":24.240226,"lon":120.564013,"t":1794376536000,"rx":1794376575379},{"id":"782-U8","dir":0,"lat":24.294138,"lon":120.588991,"t":1794376540000,"rx":1794376575379},{"id":"EAL-1378","dir":0,"lat":24.178648,"lon":120.62411,"t":1794376542000,"rx":1794376575379},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.56417,"t":1794376549000,"rx":1794376575379},{"id":"EAL-2225","dir":0,"lat":24.213326,"lon":120.580825,"t":1794376541000,"rx":1794376575379},{"id":"EAL-2263","dir":1,"lat":24.183021,"lon":120.60329,"t":1794376546000,"rx":1794376575379},{"id":"EAL-2269","dir":1,"lat":24.247934,"lon":120.575076,"t":1794376545000,"rx":1794376575379},{"id":"EAL-5365","dir":0,"lat":24.233374,"lon":120.571749,"t":1794376544000,"rx":1794376575379},{"id":"EAL-5390","dir":1,"lat":24.183109,"lon":120.603456,"t":1794376544000,"rx":1794376575379},{"id":"EAL-6027","dir":0,"lat":24.2648,"lon":120.541015,"t":1794376542000,"rx":1794376575379},{"id":"EAL-6028","dir":1,"lat":24.15453,"lon":120.664868,"t":1794376549000,"rx":1794376575379},{"id":"EAL-6029","dir":1,"lat":24.195515,"lon":120.58037,"t":1794376550000,"rx":1794376575379},{"id":"EAR-1003","dir":0,"lat":24.164664,"lon":120.645471,"t":1794376540000,"rx":1794376575379},{"id":"EAR-1015","dir":1,"lat":24.210376,"lon":120.581418,"t":1794376539000,"rx":1794376575379},{"id":"EAR-1017","dir":0,"lat":24.150988,"lon":120.669534,"t":1794376549000,"rx":1794376575379},{"id":"FAE-800","dir":0,"lat":24.240883,"lon":120.564213,"t":1794376542000,"rx":1794376575379},{"id":"757-U8","dir":0,"lat":24.261023,"lon":120.521969,"t":1794376571000,"rx":1794376599376},{"id":"762-U8","dir":0,"lat":24.259151,"lon":120.522108,"t":1794376568000,"rx":1794376599376},{"id":"763-U8","dir":0,"lat":24.142126,"lon":120.679986,"t":1794376564000,"rx":1794376599376},{"id":"780-U8","dir":0,"lat":24.240666,"lon":120.564653,"t":1794376576000,"rx":1794376599376},{"id":"782-U8","dir":0,"lat":24.294136,"lon":120.588991,"t":1794376580000,"rx":1794376599376},{"id":"EAL-1378","dir":0,"lat":24.17847,"lon":120.624465,"t":1794376576000,"rx":1794376599376},{"id":"EAL-1589","dir":0,"lat":24.261341,"lon":120.574443,"t":1794376572000,"rx":1794376599376},{"id":"EAL-2032","dir":0,"lat":24.240136,"lon":120.56417,"t":1794376569000,"rx":1794376599376},{"id":"EAL-2225","dir":0,"lat":24.210106,"lon":120.581183,"t":1794376574000,"rx":1794376599376},{"id":"EAL-2263","dir":1,"lat":24.182874,"lon":120.602479,"t":1794376575000,"rx":1794376599376},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376572000,"rx":1794376599376},{"id":"EAL-2269","dir":1,"lat":24.247933,"lon":120.575076,"t":1794376565000,"rx":1794376599376},{"id":"EAL-5358","dir":1,"lat":24.155515,"lon":120.663061,"t":1794376575000,"rx":1794376599376},{"id":"EAL-5365","dir":0,"lat":24.232616,"lon":120.572214,"t":1794376564000,"rx":1794376599376},{"id":"EAL-5390","dir":1,"lat":24.182905,"lon":120.602431,"t":1794376579000,"rx":1794376599376},{"id":"EAL-6027","dir":0,"lat":24.2648,"lon":120.541031,"t":1794376562000,"rx":1794376599376},{"id":"EAL-6028","dir":1,"lat":24.154689,"lon":120.664535,"t":1794376577000,"rx":1794376599376},{"id":"EAL-6029","dir":1,"lat":24.19939,"lon":120.580804,"t":1794376578000,"rx":1794376599376},{"id":"EAR-1003","dir":0,"lat":24.164548,"lon":120.645736,"t":1794376580000,"rx":1794376599376},{"id":"EAR-1015","dir":1,"lat":24.214528,"lon":120.580918,"t":1794376579000,"rx":1794376599376},{"id":"EAR-1017","dir":0,"lat":24.150986,"lon":120.669536,"t":1794376569000,"rx":1794376599376},{"id":"FAE-800","dir":0,"lat":24.240881,"lon":120.564213,"t":1794376562000,"rx":1794376599376},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521953,"t":1794376591000,"rx":1794376623337},{"id":"762-U8","dir":0,"lat":24.259153,"lon":120.522091,"t":1794376588000,"rx":1794376623337},{"id":"763-U8","dir":0,"lat":24.142116,"lon":120.68008,"t":1794376584000,"rx":1794376623337},{"id":"EAL-1378","dir":0,"lat":24.178218,"lon":120.625034,"t":1794376582000,"rx":1794376623337},{"id":"EAL-1589","dir":0,"lat":24.259119,"lon":120.575426,"t":1794376592000,"rx":1794376623337},{"id":"EAL-2032","dir":0,"lat":24.240136,"lon":120.564186,"t":1794376589000,"rx":1794376623337},{"id":"EAL-2225","dir":0,"lat":24.210039,"lon":120.581108,"t":1794376594000,"rx":1794376623337},{"id":"EAL-2263","dir":1,"lat":24.182671,"lon":120.601563,"t":1794376586000,"rx":1794376623337},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376592000,"rx":1794376623337},{"id":"EAL-2269","dir":1,"lat":24.247933,"lon":120.575093,"t":1794376585000,"rx":1794376623337},{"id":"EAL-5365","dir":0,"lat":24.232538,"lon":120.572271,"t":1794376584000,"rx":1794376623337},{"id":"EAL-5390","dir":1,"lat":24.182801,"lon":120.601953,"t":1794376584000,"rx":1794376623337},{"id":"EAL-6027","dir":0,"lat":24.264801,"lon":120.540993,"t":1794376582000,"rx":1794376623337},{"id":"EAR-1017","dir":0,"lat":24.150986,"lon":120.669539,"t":1794376589000,"rx":1794376623337},{"id":"FAE-800","dir":0,"lat":24.240878,"lon":120.564211,"t":1794376582000,"rx":1794376623337},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521969,"t":1794376611000,"rx":1794376647774},{"id":"762-U8","dir":0,"lat":24.259153,"lon":120.52209,"t":1794376608000,"rx":1794376647774},{"id":"763-U8","dir":0,"lat":24.141809,"lon":120.680373,"t":1794376624000,"rx":1794376647774},{"id":"780-U8","dir":0,"lat":24.23936,"lon":120.564893,"t":1794376616000,"rx":1794376647774},{"id":"782-U8","dir":0,"lat":24.294135,"lon":120.588973,"t":1794376620000,"rx":1794376647774},{"id":"EAL-1378","dir":0,"lat":24.175879,"lon":120.629769,"t":1794376622000,"rx":1794376647774},{"id":"EAL-1589","dir":0,"lat":24.256673,"lon":120.574868,"t":1794376612000,"rx":1794376647774},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.56417,"t":1794376609000,"rx":1794376647774},{"id":"EAL-2225","dir":0,"lat":24.210039,"lon":120.581093,"t":1794376614000,"rx":1794376647774},{"id":"EAL-2263","dir":1,"lat":24.182103,"lon":120.598389,"t":1794376618000,"rx":1794376647774},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376612000,"rx":1794376647774},{"id":"EAL-2269","dir":1,"lat":24.247921,"lon":120.5751,"t":1794376625000,"rx":1794376647774},{"id":"EAL-5358","dir":1,"lat":24.155568,"lon":120.662796,"t":1794376615000,"rx":1794376647774},{"id":"EAL-5365","dir":0,"lat":24.23254,"lon":120.572289,"t":1794376624000,"rx":1794376647774},{"id":"EAL-5390","dir":1,"lat":24.182138,"lon":120.598091,"t":1794376624000,"rx":1794376647774},{"id":"EAL-6027","dir":0,"lat":24.264818,"lon":120.540994,"t":1794376622000,"rx":1794376647774},{"id":"EAL-6028","dir":1,"lat":24.154578,"lon":120.66455,"t":1794376617000,"rx":1794376647774},{"id":"EAL-6029","dir":1,"lat":24.201313,"lon":120.581048,"t":1794376618000,"rx":1794376647774},{"id":"EAR-1003","dir":0,"lat":24.164546,"lon":120.64575,"t":1794376620000,"rx":1794376647774},{"id":"EAR-1015","dir":1,"lat":24.214539,"lon":120.58092,"t":1794376619000,"rx":1794376647774},{"id":"EAR-1017","dir":0,"lat":24.150964,"lon":120.669534,"t":1794376609000,"rx":1794376647774},{"id":"FAE-800","dir":0,"lat":24.240876,"lon":120.564206,"t":1794376622000,"rx":1794376647774},{"id":"757-U8","dir":0,"lat":24.261025,"lon":120.521969,"t":1794376651000,"rx":1794376671340},{"id":"762-U8","dir":0,"lat":24.259153,"lon":120.52209,"t":1794376648000,"rx":1794376671340},{"id":"763-U8","dir":0,"lat":24.141493,"lon":120.680653,"t":1794376644000,"rx":1794376671340},{"id":"780-U8","dir":0,"lat":24.238603,"lon":120.56704,"t":1794376636000,"rx":1794376671340},{"id":"782-U8","dir":0,"lat":24.294135,"lon":120.588989,"t":1794376640000,"rx":1794376671340},{"id":"EAL-1378","dir":0,"lat":24.174275,"lon":120.632216,"t":1794376642000,"rx":1794376671340},{"id":"EAL-1589","dir":0,"lat":24.252289,"lon":120.575175,"t":1794376652000,"rx":1794376671340},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.56417,"t":1794376649000,"rx":1794376671340},{"id":"EAL-2225","dir":0,"lat":24.206936,"lon":120.58126,"t":1794376654000,"rx":1794376671340},{"id":"EAL-2263","dir":1,"lat":24.182358,"lon":120.595511,"t":1794376646000,"rx":1794376671340},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376652000,"rx":1794376671340},{"id":"EAL-2269","dir":1,"lat":24.247918,"lon":120.5751,"t":1794376645000,"rx":1794376671340},{"id":"EAL-5358","dir":1,"lat":24.155598,"lon":120.662869,"t":1794376635000,"rx":1794376671340},{"id":"EAL-5365","dir":0,"lat":24.23254,"lon":120.572275,"t":1794376644000,"rx":1794376671340},{"id":"EAL-5390","dir":1,"lat":24.18232,"lon":120.595984,"t":1794376644000,"rx":1794376671340},{"id":"EAL-6027","dir":0,"lat":24.264818,"lon":120.540996,"t":1794376642000,"rx":1794376671340},{"id":"EAL-6028","dir":1,"lat":24.155373,"lon":120.663214,"t":1794376651000,"rx":1794376671340},{"id":"EAL-6029","dir":1,"lat":24.20137,"lon":120.581056,"t":1794376647000,"rx":1794376671340},{"id":"EAR-1003","dir":0,"lat":24.164186,"lon":120.646543,"t":1794376642000,"rx":1794376671340},{"id":"EAR-1015","dir":1,"lat":24.215931,"lon":120.58073,"t":1794376641000,"rx":1794376671340},{"id":"EAR-1017","dir":0,"lat":24.150628,"lon":120.669958,"t":1794376650000,"rx":1794376671340},{"id":"FAE-800","dir":0,"lat":24.240876,"lon":120.564205,"t":1794376642000,"rx":1794376671340},{"id":"762-U8","dir":0,"lat":24.259153,"lon":120.522106,"t":1794376668000,"rx":1794376695400},{"id":"763-U8","dir":0,"lat":24.140613,"lon":120.681533,"t":1794376664000,"rx":1794376695400},{"id":"780-U8","dir":0,"lat":24.23722,"lon":120.569346,"t":1794376656000,"rx":1794376695400},{"id":"782-U8","dir":0,"lat":24.294125,"lon":120.588968,"t":1794376660000,"rx":1794376695400},{"id":"EAL-1378","dir":0,"lat":24.172444,"lon":120.634598,"t":1794376662000,"rx":1794376695400},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564186,"t":1794376669000,"rx":1794376695400},{"id":"EAL-2263","dir":1,"lat":24.182823,"lon":120.593866,"t":1794376666000,"rx":1794376695400},{"id":"EAL-2269","dir":1,"lat":24.24792,"lon":120.5751,"t":1794376665000,"rx":1794376695400},{"id":"EAL-5358","dir":1,"lat":24.15615,"lon":120.661595,"t":1794376668000,"rx":1794376695400},{"id":"EAL-5365","dir":0,"lat":24.232538,"lon":120.572276,"t":1794376664000,"rx":1794376695400},{"id":"EAL-5390","dir":1,"lat":24.182745,"lon":120.594243,"t":1794376664000,"rx":1794376695400},{"id":"EAL-6027","dir":0,"lat":24.2648,"lon":120.541003,"t":1794376662000,"rx":1794376695400},{"id":"EAL-6028","dir":1,"lat":24.155468,"lon":120.663145,"t":1794376657000,"rx":1794376695400},{"id":"EAL-6029","dir":1,"lat":24.202396,"lon":120.581154,"t":1794376658000,"rx":1794376695400},{"id":"EAR-1003","dir":0,"lat":24.163068,"lon":120.648516,"t":1794376660000,"rx":1794376695400},{"id":"EAR-1015","dir":1,"lat":24.216956,"lon":120.580596,"t":1794376659000,"rx":1794376695400},{"id":"EAR-1017","dir":0,"lat":24.149316,"lon":120.671526,"t":1794376669000,"rx":1794376695400},{"id":"FAE-800","dir":0,"lat":24.240874,"lon":120.564201,"t":1794376662000,"rx":1794376695400},{"id":"757-U8","dir":0,"lat":24.260976,"lon":120.521945,"t":1794376691000,"rx":1794376719385},{"id":"762-U8","dir":0,"lat":24.259164,"lon":120.522069,"t":1794376688000,"rx":1794376719385},{"id":"763-U8","dir":0,"lat":24.140006,"lon":120.682253,"t":1794376684000,"rx":1794376719385},{"id":"780-U8","dir":0,"lat":24.232726,"lon":120.572253,"t":1794376696000,"rx":1794376719385},{"id":"782-U8","dir":0,"lat":24.294131,"lon":120.588988,"t":1794376700000,"rx":1794376719385},{"id":"EAL-1378","dir":0,"lat":24.170911,"lon":120.636578,"t":1794376682000,"rx":1794376719385},{"id":"EAL-1589","dir":0,"lat":24.248513,"lon":120.575446,"t":1794376692000,"rx":1794376719385},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564171,"t":1794376689000,"rx":1794376719385},{"id":"EAL-2225","dir":0,"lat":24.201278,"lon":120.580626,"t":1794376694000,"rx":1794376719385},{"id":"EAL-2263","dir":1,"lat":24.183305,"lon":120.59274,"t":1794376700000,"rx":1794376719385},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576546,"t":1794376694000,"rx":1794376719385},{"id":"EAL-2269","dir":1,"lat":24.247941,"lon":120.575095,"t":1794376685000,"rx":1794376719385},{"id":"EAL-5358","dir":1,"lat":24.157593,"lon":120.659093,"t":1794376695000,"rx":1794376719385},{"id":"EAL-5365","dir":0,"lat":24.23254,"lon":120.572273,"t":1794376684000,"rx":1794376719385},{"id":"EAL-5390","dir":1,"lat":24.182891,"lon":120.593903,"t":1794376684000,"rx":1794376719385},{"id":"EAL-6027","dir":0,"lat":24.264801,"lon":120.541003,"t":1794376682000,"rx":1794376719385},{"id":"EAL-6028","dir":1,"lat":24.156418,"lon":120.661381,"t":1794376697000,"rx":1794376719385},{"id":"EAL-6029","dir":1,"lat":24.207958,"lon":120.581643,"t":1794376699000,"rx":1794376719385},{"id":"EAR-1003","dir":0,"lat":24.162626,"lon":120.649253,"t":1794376700000,"rx":1794376719385},{"id":"EAR-1015","dir":1,"lat":24.220396,"lon":120.580053,"t":1794376699000,"rx":1794376719385},{"id":"EAR-1017","dir":0,"lat":24.149201,"lon":120.671626,"t":1794376689000,"rx":1794376719385},{"id":"FAE-800","dir":0,"lat":24.240874,"lon":120.564218,"t":1794376682000,"rx":1794376719385},{"id":"757-U8","dir":0,"lat":24.260131,"lon":120.521685,"t":1794376711000,"rx":1794376743335},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522068,"t":1794376708000,"rx":1794376743335},{"id":"763-U8","dir":0,"lat":24.139506,"lon":120.682826,"t":1794376710000,"rx":1794376743335},{"id":"EAL-1378","dir":0,"lat":24.169528,"lon":120.638428,"t":1794376702000,"rx":1794376743335},{"id":"EAL-1589","dir":0,"lat":24.248513,"lon":120.575446,"t":1794376712000,"rx":1794376743335},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564188,"t":1794376709000,"rx":1794376743335},{"id":"EAL-2225","dir":0,"lat":24.198254,"lon":120.580343,"t":1794376715000,"rx":1794376743335},{"id":"EAL-2263","dir":1,"lat":24.18353,"lon":120.592218,"t":1794376706000,"rx":1794376743335},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376714000,"rx":1794376743335},{"id":"EAL-2269","dir":1,"lat":24.247946,"lon":120.575101,"t":1794376705000,"rx":1794376743335},{"id":"EAL-5358","dir":1,"lat":24.157881,"lon":120.65858,"t":1794376713000,"rx":1794376743335},{"id":"EAL-5365","dir":0,"lat":24.23254,"lon":120.572275,"t":1794376704000,"rx":1794376743335},{"id":"EAL-5390","dir":1,"lat":24.183008,"lon":120.593548,"t":1794376704000,"rx":1794376743335},{"id":"EAL-6027","dir":0,"lat":24.26479,"lon":120.541006,"t":1794376702000,"rx":1794376743335},{"id":"EAL-6028","dir":1,"lat":24.156861,"lon":120.660589,"t":1794376704000,"rx":1794376743335},{"id":"EAL-6029","dir":1,"lat":24.208956,"lon":120.581563,"t":1794376711000,"rx":1794376743335},{"id":"EAR-1003","dir":0,"lat":24.162331,"lon":120.649703,"t":1794376705000,"rx":1794376743335},{"id":"EAR-1015","dir":1,"lat":24.221788,"lon":120.579483,"t":1794376714000,"rx":1794376743335},{"id":"EAR-1017","dir":0,"lat":24.14833,"lon":120.672634,"t":1794376709000,"rx":1794376743335},{"id":"FAE-800","dir":0,"lat":24.240874,"lon":120.564201,"t":1794376702000,"rx":1794376743335},{"id":"762-U8","dir":0,"lat":24.259171,"lon":120.522068,"t":1794376728000,"rx":1794376755658},{"id":"763-U8","dir":0,"lat":24.139119,"lon":120.683413,"t":1794376724000,"rx":1794376755658},{"id":"780-U8","dir":0,"lat":24.230636,"lon":120.573573,"t":1794376716000,"rx":1794376755658},{"id":"782-U8","dir":0,"lat":24.294146,"lon":120.588956,"t":1794376720000,"rx":1794376755658},{"id":"EAL-1378","dir":0,"lat":24.169181,"lon":120.638906,"t":1794376722000,"rx":1794376755658},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564171,"t":1794376729000,"rx":1794376755658},{"id":"EAL-2225","dir":0,"lat":24.196058,"lon":120.580173,"t":1794376729000,"rx":1794376755658},{"id":"EAL-2263","dir":1,"lat":24.184313,"lon":120.590353,"t":1794376727000,"rx":1794376755658},{"id":"EAL-2269","dir":1,"lat":24.247941,"lon":120.5751,"t":1794376725000,"rx":1794376755658},{"id":"EAL-5358","dir":1,"lat":24.157963,"lon":120.658408,"t":1794376715000,"rx":1794376755658},{"id":"EAL-5365","dir":0,"lat":24.232546,"lon":120.572283,"t":1794376724000,"rx":1794376755658},{"id":"EAL-5390","dir":1,"lat":24.183381,"lon":120.592601,"t":1794376724000,"rx":1794376755658},{"id":"EAL-6027","dir":0,"lat":24.264771,"lon":120.54101,"t":1794376722000,"rx":1794376755658},{"id":"EAL-6028","dir":1,"lat":24.157661,"lon":120.659163,"t":1794376721000,"rx":1794376755658},{"id":"EAL-6029","dir":1,"lat":24.209834,"lon":120.581448,"t":1794376727000,"rx":1794376755658},{"id":"EAR-1003","dir":0,"lat":24.160828,"lon":120.652636,"t":1794376728000,"rx":1794376755658},{"id":"EAR-1015","dir":1,"lat":24.223786,"lon":120.578218,"t":1794376729000,"rx":1794376755658},{"id":"EAR-1017","dir":0,"lat":24.147745,"lon":120.673276,"t":1794376729000,"rx":1794376755658},{"id":"FAE-800","dir":0,"lat":24.240873,"lon":120.564201,"t":1794376722000,"rx":1794376755658},{"id":"757-U8","dir":0,"lat":24.259758,"lon":120.522193,"t":1794376751000,"rx":1794376779667},{"id":"762-U8","dir":0,"lat":24.259176,"lon":120.522075,"t":1794376748000,"rx":1794376779667},{"id":"763-U8","dir":0,"lat":24.13894,"lon":120.683639,"t":1794376744000,"rx":1794376779667},{"id":"780-U8","dir":0,"lat":24.230066,"lon":120.573946,"t":1794376756000,"rx":1794376779667},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.588951,"t":1794376760000,"rx":1794376779667},{"id":"EAL-1378","dir":0,"lat":24.169154,"lon":120.638963,"t":1794376742000,"rx":1794376779667},{"id":"EAL-1589","dir":0,"lat":24.248513,"lon":120.575446,"t":1794376752000,"rx":1794376779667},{"id":"EAL-2032","dir":0,"lat":24.240134,"lon":120.564188,"t":1794376749000,"rx":1794376779667},{"id":"EAL-2225","dir":0,"lat":24.19299,"lon":120.579803,"t":1794376754000,"rx":1794376779667},{"id":"EAL-2263","dir":1,"lat":24.185123,"lon":120.588466,"t":1794376746000,"rx":1794376779667},{"id":"EAL-2265","dir":1,"lat":24.226493,"lon":120.576563,"t":1794376754000,"rx":1794376779667},{"id":"EAL-2269","dir":1,"lat":24.247933,"lon":120.5751,"t":1794376745000,"rx":1794376779667},{"id":"EAL-5358","dir":1,"lat":24.15818,"lon":120.658019,"t":1794376755000,"rx":1794376779667},{"id":"EAL-5365","dir":0,"lat":24.232545,"lon":120.572291,"t":1794376744000,"rx":1794376779667},{"id":"EAL-5390","dir":1,"lat":24.184841,"lon":120.589183,"t":1794376752000,"rx":1794376779667},{"id":"EAL-6027","dir":0,"lat":24.264773,"lon":120.541011,"t":1794376742000,"rx":1794376779667},{"id":"EAL-6028","dir":1,"lat":24.158854,"lon":120.657353,"t":1794376757000,"rx":1794376779667},{"id":"EAL-6029","dir":1,"lat":24.214013,"lon":120.580951,"t":1794376759000,"rx":1794376779667},{"id":"EAR-1003","dir":0,"lat":24.160783,"lon":120.652821,"t":1794376760000,"rx":1794376779667},{"id":"EAR-1015","dir":1,"lat":24.225374,"lon":120.577185,"t":1794376759000,"rx":1794376779667},{"id":"EAR-1017","dir":0,"lat":24.147743,"lon":120.673261,"t":1794376749000,"rx":1794376779667},{"id":"FAE-800","dir":0,"lat":24.240873,"lon":120.564218,"t":1794376742000,"rx":1794376779667},{"id":"757-U8","dir":0,"lat":24.259591,"lon":120.522803,"t":1794376771000,"rx":1794376803458},{"id":"762-U8","dir":0,"lat":24.259174,"lon":120.522064,"t":1794376768000,"rx":1794376803458},{"id":"763-U8","dir":0,"lat":24.138649,"lon":120.683946,"t":1794376764000,"rx":1794376803458},{"id":"EAL-1378","dir":0,"lat":24.169154,"lon":120.638968,"t":1794376762000,"rx":1794376803458},{"id":"EAL-1589","dir":0,"lat":24.2482,"lon":120.575311,"t":1794376772000,"rx":1794376803458},{"id":"EAL-2032","dir":0,"lat":24.240136,"lon":120.564171,"t":1794376769000,"rx":1794376803458},{"id":"EAL-2225","dir":0,"lat":24.192746,"lon":120.579786,"t":1794376774000,"rx":1794376803458},{"id":"EAL-2263","dir":1,"lat":24.185289,"lon":120.588091,"t":1794376766000,"rx":1794376803458},{"id":"EAL-2265","dir":1,"lat":24.226491,"lon":120.576554,"t":1794376774000,"rx":1794376803458},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575093,"t":1794376765000,"rx":1794376803458},{"id":"EAL-5365","dir":0,"lat":24.232533,"lon":120.572306,"t":1794376764000,"rx":1794376803458},{"id":"EAL-5390","dir":1,"lat":24.185218,"lon":120.58831,"t":1794376764000,"rx":1794376803458},{"id":"EAL-6027","dir":0,"lat":24.264773,"lon":120.541013,"t":1794376762000,"rx":1794376803458},{"id":"EAL-6029","dir":1,"lat":24.21415,"lon":120.580936,"t":1794376761000,"rx":1794376803458},{"id":"EAR-1017","dir":0,"lat":24.147741,"lon":120.673259,"t":1794376769000,"rx":1794376803458},{"id":"FAE-800","dir":0,"lat":24.240873,"lon":120.564199,"t":1794376762000,"rx":1794376803458},{"id":"762-U8","dir":0,"lat":24.259174,"lon":120.522081,"t":1794376788000,"rx":1794376827482},{"id":"763-U8","dir":0,"lat":24.138546,"lon":120.684026,"t":1794376804000,"rx":1794376827482},{"id":"780-U8","dir":0,"lat":24.230083,"lon":120.573959,"t":1794376796000,"rx":1794376827482},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.58897,"t":1794376800000,"rx":1794376827482},{"id":"EAL-1378","dir":0,"lat":24.169045,"lon":120.639165,"t":1794376802000,"rx":1794376827482},{"id":"EAL-1589","dir":0,"lat":24.24696,"lon":120.574446,"t":1794376792000,"rx":1794376827482},{"id":"EAL-2032","dir":0,"lat":24.240136,"lon":120.564188,"t":1794376789000,"rx":1794376827482},{"id":"EAL-2225","dir":0,"lat":24.192741,"lon":120.579791,"t":1794376794000,"rx":1794376827482},{"id":"EAL-2263","dir":1,"lat":24.185304,"lon":120.588075,"t":1794376786000,"rx":1794376827482},{"id":"EAL-2265","dir":1,"lat":24.226511,"lon":120.576594,"t":1794376794000,"rx":1794376827482},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575093,"t":1794376805000,"rx":1794376827482},{"id":"EAL-5358","dir":1,"lat":24.158188,"lon":120.658011,"t":1794376795000,"rx":1794376827482},{"id":"EAL-5365","dir":0,"lat":24.232533,"lon":120.572308,"t":1794376804000,"rx":1794376827482},{"id":"EAL-5390","dir":1,"lat":24.185214,"lon":120.588273,"t":1794376804000,"rx":1794376827482},{"id":"EAL-6027","dir":0,"lat":24.264775,"lon":120.541031,"t":1794376802000,"rx":1794376827482},{"id":"EAL-6028","dir":1,"lat":24.158805,"lon":120.657425,"t":1794376797000,"rx":1794376827482},{"id":"EAL-6029","dir":1,"lat":24.214411,"lon":120.580923,"t":1794376798000,"rx":1794376827482},{"id":"EAR-1003","dir":0,"lat":24.160783,"lon":120.652839,"t":1794376800000,"rx":1794376827482},{"id":"EAR-1015","dir":1,"lat":24.225383,"lon":120.577179,"t":1794376799000,"rx":1794376827482},{"id":"EAR-1017","dir":0,"lat":24.147751,"lon":120.673294,"t":1794376789000,"rx":1794376827482},{"id":"FAE-800","dir":0,"lat":24.240871,"lon":120.564198,"t":1794376802000,"rx":1794376827482},{"id":"762-U8","dir":0,"lat":24.259163,"lon":120.522094,"t":1794376828000,"rx":1794376851441},{"id":"763-U8","dir":0,"lat":24.137689,"lon":120.684666,"t":1794376824000,"rx":1794376851441},{"id":"780-U8","dir":0,"lat":24.229423,"lon":120.574359,"t":1794376816000,"rx":1794376851441},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.588953,"t":1794376820000,"rx":1794376851441},{"id":"EAL-1378","dir":0,"lat":24.167016,"lon":120.641748,"t":1794376833000,"rx":1794376851441},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794376832000,"rx":1794376851441},{"id":"EAL-2032","dir":0,"lat":24.240136,"lon":120.564189,"t":1794376829000,"rx":1794376851441},{"id":"EAL-2225","dir":0,"lat":24.189148,"lon":120.579951,"t":1794376834000,"rx":1794376851441},{"id":"EAL-2263","dir":1,"lat":24.185893,"lon":120.586719,"t":1794376826000,"rx":1794376851441},{"id":"EAL-2265","dir":1,"lat":24.226503,"lon":120.576581,"t":1794376834000,"rx":1794376851441},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575109,"t":1794376825000,"rx":1794376851441},{"id":"EAL-5358","dir":1,"lat":24.158725,"lon":120.65693,"t":1794376824000,"rx":1794376851441},{"id":"EAL-5365","dir":0,"lat":24.232533,"lon":120.572325,"t":1794376824000,"rx":1794376851441},{"id":"EAL-5390","dir":1,"lat":24.185928,"lon":120.586759,"t":1794376833000,"rx":1794376851441},{"id":"EAL-6027","dir":0,"lat":24.264776,"lon":120.541008,"t":1794376822000,"rx":1794376851441},{"id":"EAL-6028","dir":1,"lat":24.15894,"lon":120.656323,"t":1794376834000,"rx":1794376851441},{"id":"EAL-6029","dir":1,"lat":24.216801,"lon":120.580606,"t":1794376832000,"rx":1794376851441},{"id":"EAR-1003","dir":0,"lat":24.160783,"lon":120.652823,"t":1794376820000,"rx":1794376851441},{"id":"EAR-1015","dir":1,"lat":24.225455,"lon":120.577134,"t":1794376819000,"rx":1794376851441},{"id":"EAR-1017","dir":0,"lat":24.146336,"lon":120.675018,"t":1794376829000,"rx":1794376851441},{"id":"FAE-800","dir":0,"lat":24.240871,"lon":120.564196,"t":1794376822000,"rx":1794376851441},{"id":"762-U8","dir":0,"lat":24.25916,"lon":120.522083,"t":1794376848000,"rx":1794376875416},{"id":"763-U8","dir":0,"lat":24.137706,"lon":120.686253,"t":1794376844000,"rx":1794376875416},{"id":"780-U8","dir":0,"lat":24.227293,"lon":120.57572,"t":1794376836000,"rx":1794376875416},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.58897,"t":1794376840000,"rx":1794376875416},{"id":"EAL-1378","dir":0,"lat":24.166176,"lon":120.642803,"t":1794376845000,"rx":1794376875416},{"id":"EAL-2032","dir":0,"lat":24.240136,"lon":120.564173,"t":1794376849000,"rx":1794376875416},{"id":"EAL-2263","dir":1,"lat":24.186043,"lon":120.586295,"t":1794376846000,"rx":1794376875416},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575093,"t":1794376845000,"rx":1794376875416},{"id":"EAL-5358","dir":1,"lat":24.159098,"lon":120.6563,"t":1794376835000,"rx":1794376875416},{"id":"EAL-5365","dir":0,"lat":24.232533,"lon":120.572308,"t":1794376844000,"rx":1794376875416},{"id":"EAL-5390","dir":1,"lat":24.186031,"lon":120.586528,"t":1794376844000,"rx":1794376875416},{"id":"EAL-6027","dir":0,"lat":24.26478,"lon":120.54101,"t":1794376842000,"rx":1794376875416},{"id":"EAL-6028","dir":1,"lat":24.159038,"lon":120.656324,"t":1794376848000,"rx":1794376875416},{"id":"EAL-6029","dir":1,"lat":24.216819,"lon":120.580603,"t":1794376838000,"rx":1794376875416},{"id":"EAR-1003","dir":0,"lat":24.159658,"lon":120.654791,"t":1794376840000,"rx":1794376875416},{"id":"EAR-1015","dir":1,"lat":24.226988,"lon":120.576068,"t":1794376839000,"rx":1794376875416},{"id":"EAR-1017","dir":0,"lat":24.146116,"lon":120.675308,"t":1794376849000,"rx":1794376875416},{"id":"FAE-800","dir":0,"lat":24.240871,"lon":120.564213,"t":1794376842000,"rx":1794376875416},{"id":"762-U8","dir":0,"lat":24.25916,"lon":120.522088,"t":1794376868000,"rx":1794376899397},{"id":"763-U8","dir":0,"lat":24.137916,"lon":120.686639,"t":1794376864000,"rx":1794376899397},{"id":"780-U8","dir":0,"lat":24.226716,"lon":120.576053,"t":1794376876000,"rx":1794376899397},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.58897,"t":1794376880000,"rx":1794376899397},{"id":"EAL-1378","dir":0,"lat":24.166006,"lon":120.643081,"t":1794376862000,"rx":1794376899397},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794376872000,"rx":1794376899397},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564175,"t":1794376869000,"rx":1794376899397},{"id":"EAL-2225","dir":0,"lat":24.186375,"lon":120.585104,"t":1794376874000,"rx":1794376899397},{"id":"EAL-2263","dir":1,"lat":24.186724,"lon":120.584766,"t":1794376866000,"rx":1794376899397},{"id":"EAL-2265","dir":1,"lat":24.226498,"lon":120.576606,"t":1794376874000,"rx":1794376899397},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575109,"t":1794376865000,"rx":1794376899397},{"id":"EAL-5358","dir":1,"lat":24.160986,"lon":120.652951,"t":1794376875000,"rx":1794376899397},{"id":"EAL-5365","dir":0,"lat":24.232531,"lon":120.572308,"t":1794376864000,"rx":1794376899397},{"id":"EAL-5390","dir":1,"lat":24.186543,"lon":120.585356,"t":1794376876000,"rx":1794376899397},{"id":"EAL-6027","dir":0,"lat":24.264781,"lon":120.541011,"t":1794376862000,"rx":1794376899397},{"id":"EAL-6028","dir":1,"lat":24.160531,"lon":120.653726,"t":1794376877000,"rx":1794376899397},{"id":"EAL-6029","dir":1,"lat":24.21997,"lon":120.580176,"t":1794376878000,"rx":1794376899397},{"id":"EAR-1003","dir":0,"lat":24.158336,"lon":120.65712,"t":1794376880000,"rx":1794376899397},{"id":"EAR-1015","dir":1,"lat":24.227081,"lon":120.576023,"t":1794376879000,"rx":1794376899397},{"id":"EAR-1017","dir":0,"lat":24.144203,"lon":120.677463,"t":1794376878000,"rx":1794376899397},{"id":"FAE-800","dir":0,"lat":24.240871,"lon":120.564196,"t":1794376862000,"rx":1794376899397},{"id":"762-U8","dir":0,"lat":24.25916,"lon":120.522104,"t":1794376888000,"rx":1794376923520},{"id":"763-U8","dir":0,"lat":24.13792,"lon":120.686746,"t":1794376884000,"rx":1794376923520},{"id":"EAL-1378","dir":0,"lat":24.166006,"lon":120.643081,"t":1794376882000,"rx":1794376923520},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794376892000,"rx":1794376923520},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564191,"t":1794376889000,"rx":1794376923520},{"id":"EAL-2225","dir":0,"lat":24.185933,"lon":120.586074,"t":1794376894000,"rx":1794376923520},{"id":"EAL-2263","dir":1,"lat":24.187731,"lon":120.582343,"t":1794376886000,"rx":1794376923520},{"id":"EAL-2265","dir":1,"lat":24.226494,"lon":120.57661,"t":1794376894000,"rx":1794376923520},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575093,"t":1794376885000,"rx":1794376923520},{"id":"EAL-5358","dir":1,"lat":24.161829,"lon":120.65142,"t":1794376886000,"rx":1794376923520},{"id":"EAL-5365","dir":0,"lat":24.232531,"lon":120.572325,"t":1794376884000,"rx":1794376923520},{"id":"EAL-5390","dir":1,"lat":24.187061,"lon":120.584166,"t":1794376884000,"rx":1794376923520},{"id":"EAL-6027","dir":0,"lat":24.264781,"lon":120.541028,"t":1794376882000,"rx":1794376923520},{"id":"EAL-6028","dir":1,"lat":24.160889,"lon":120.653059,"t":1794376882000,"rx":1794376923520},{"id":"EAL-6029","dir":1,"lat":24.221031,"lon":120.579779,"t":1794376891000,"rx":1794376923520},{"id":"EAR-1003","dir":0,"lat":24.158045,"lon":120.657661,"t":1794376887000,"rx":1794376923520},{"id":"EAR-1017","dir":0,"lat":24.143429,"lon":120.678323,"t":1794376890000,"rx":1794376923520},{"id":"FAE-800","dir":0,"lat":24.24087,"lon":120.564196,"t":1794376882000,"rx":1794376923520},{"id":"762-U8","dir":0,"lat":24.25916,"lon":120.522088,"t":1794376908000,"rx":1794376947815},{"id":"763-U8","dir":1,"lat":24.13813,"lon":120.687386,"t":1794376924000,"rx":1794376947815},{"id":"780-U8","dir":0,"lat":24.226736,"lon":120.57608,"t":1794376916000,"rx":1794376947815},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.58897,"t":1794376920000,"rx":1794376947815},{"id":"EAL-1378","dir":0,"lat":24.16465,"lon":120.6456,"t":1794376922000,"rx":1794376947815},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794376912000,"rx":1794376947815},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564175,"t":1794376909000,"rx":1794376947815},{"id":"EAL-2225","dir":0,"lat":24.184936,"lon":120.588223,"t":1794376914000,"rx":1794376947815},{"id":"EAL-2263","dir":1,"lat":24.188598,"lon":120.580678,"t":1794376906000,"rx":1794376947815},{"id":"EAL-2265","dir":1,"lat":24.226496,"lon":120.576608,"t":1794376914000,"rx":1794376947815},{"id":"EAL-2269","dir":1,"lat":24.247931,"lon":120.575095,"t":1794376925000,"rx":1794376947815},{"id":"EAL-5358","dir":1,"lat":24.162653,"lon":120.649993,"t":1794376915000,"rx":1794376947815},{"id":"EAL-5365","dir":0,"lat":24.232531,"lon":120.572325,"t":1794376924000,"rx":1794376947815},{"id":"EAL-5390","dir":1,"lat":24.188738,"lon":120.580673,"t":1794376924000,"rx":1794376947815},{"id":"EAL-6027","dir":0,"lat":24.264793,"lon":120.541008,"t":1794376922000,"rx":1794376947815},{"id":"EAL-6028","dir":1,"lat":24.162571,"lon":120.650059,"t":1794376917000,"rx":1794376947815},{"id":"EAL-6029","dir":1,"lat":24.222971,"lon":120.578708,"t":1794376918000,"rx":1794376947815},{"id":"EAR-1003","dir":0,"lat":24.157233,"lon":120.659183,"t":1794376920000,"rx":1794376947815},{"id":"EAR-1015","dir":1,"lat":24.227118,"lon":120.57553,"t":1794376919000,"rx":1794376947815},{"id":"EAR-1017","dir":0,"lat":24.143364,"lon":120.678411,"t":1794376909000,"rx":1794376947815},{"id":"FAE-800","dir":0,"lat":24.240868,"lon":120.564193,"t":1794376922000,"rx":1794376947815},{"id":"762-U8","dir":0,"lat":24.25916,"lon":120.522104,"t":1794376928000,"rx":1794376959388},{"id":"780-U8","dir":0,"lat":24.226736,"lon":120.57608,"t":1794376936000,"rx":1794376959388},{"id":"782-U8","dir":0,"lat":24.294143,"lon":120.588953,"t":1794376940000,"rx":1794376959388},{"id":"EAL-1378","dir":0,"lat":24.164538,"lon":120.645826,"t":1794376926000,"rx":1794376959388},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794376932000,"rx":1794376959388},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564191,"t":1794376929000,"rx":1794376959388},{"id":"EAL-2225","dir":0,"lat":24.184311,"lon":120.589801,"t":1794376934000,"rx":1794376959388},{"id":"EAL-2263","dir":1,"lat":24.188826,"lon":120.580483,"t":1794376926000,"rx":1794376959388},{"id":"EAL-2265","dir":1,"lat":24.226496,"lon":120.576625,"t":1794376934000,"rx":1794376959388},{"id":"EAL-5358","dir":1,"lat":24.162659,"lon":120.649979,"t":1794376935000,"rx":1794376959388},{"id":"EAL-6028","dir":1,"lat":24.162576,"lon":120.650028,"t":1794376937000,"rx":1794376959388},{"id":"EAL-6029","dir":1,"lat":24.223224,"lon":120.578523,"t":1794376938000,"rx":1794376959388},{"id":"EAR-1003","dir":0,"lat":24.157223,"lon":120.659226,"t":1794376940000,"rx":1794376959388},{"id":"EAR-1015","dir":1,"lat":24.227053,"lon":120.575463,"t":1794376939000,"rx":1794376959388},{"id":"EAR-1017","dir":0,"lat":24.143364,"lon":120.678415,"t":1794376929000,"rx":1794376959388},{"id":"763-U8","dir":1,"lat":24.138123,"lon":120.687386,"t":1794376944000,"rx":1794376983366},{"id":"EAL-1378","dir":0,"lat":24.164456,"lon":120.645981,"t":1794376942000,"rx":1794376983366},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794376952000,"rx":1794376983366},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564175,"t":1794376949000,"rx":1794376983366},{"id":"EAL-2225","dir":0,"lat":24.183708,"lon":120.591168,"t":1794376954000,"rx":1794376983366},{"id":"EAL-2263","dir":1,"lat":24.188828,"lon":120.580488,"t":1794376946000,"rx":1794376983366},{"id":"EAL-2265","dir":1,"lat":24.226496,"lon":120.576606,"t":1794376954000,"rx":1794376983366},{"id":"EAL-2269","dir":1,"lat":24.247918,"lon":120.575093,"t":1794376945000,"rx":1794376983366},{"id":"EAL-5365","dir":0,"lat":24.232531,"lon":120.572308,"t":1794376944000,"rx":1794376983366},{"id":"EAL-5390","dir":1,"lat":24.188778,"lon":120.580641,"t":1794376944000,"rx":1794376983366},{"id":"EAL-6027","dir":0,"lat":24.264803,"lon":120.541004,"t":1794376942000,"rx":1794376983366},{"id":"EAR-1015","dir":1,"lat":24.226786,"lon":120.574853,"t":1794376953000,"rx":1794376983366},{"id":"EAR-1017","dir":0,"lat":24.143348,"lon":120.678443,"t":1794376949000,"rx":1794376983366},{"id":"FAE-800","dir":0,"lat":24.240845,"lon":120.564183,"t":1794376942000,"rx":1794376983366},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.522088,"t":1794377088000,"rx":1794377115919},{"id":"763-U8","dir":1,"lat":24.140153,"lon":120.687826,"t":1794377084000,"rx":1794377115919},{"id":"780-U8","dir":0,"lat":24.221283,"lon":120.57948,"t":1794377080000,"rx":1794377115919},{"id":"782-U8","dir":0,"lat":24.294131,"lon":120.588994,"t":1794377080000,"rx":1794377115919},{"id":"EAL-1378","dir":0,"lat":24.161255,"lon":120.65192,"t":1794377085000,"rx":1794377115919},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794377072000,"rx":1794377115919},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564176,"t":1794377089000,"rx":1794377115919},{"id":"EAL-2225","dir":0,"lat":24.182573,"lon":120.594026,"t":1794377074000,"rx":1794377115919},{"id":"EAL-2263","dir":1,"lat":24.203465,"lon":120.581251,"t":1794377086000,"rx":1794377115919},{"id":"EAL-2265","dir":1,"lat":24.226513,"lon":120.576589,"t":1794377074000,"rx":1794377115919},{"id":"EAL-2269","dir":1,"lat":24.247913,"lon":120.57509,"t":1794377085000,"rx":1794377115919},{"id":"EAL-5358","dir":1,"lat":24.165793,"lon":120.644154,"t":1794377075000,"rx":1794377115919},{"id":"EAL-5365","dir":0,"lat":24.232495,"lon":120.572294,"t":1794377084000,"rx":1794377115919},{"id":"EAL-5390","dir":1,"lat":24.201066,"lon":120.580955,"t":1794377084000,"rx":1794377115919},{"id":"EAL-6027","dir":0,"lat":24.264828,"lon":120.541008,"t":1794377082000,"rx":1794377115919},{"id":"EAL-6028","dir":1,"lat":24.165735,"lon":120.644303,"t":1794377077000,"rx":1794377115919},{"id":"EAR-1003","dir":0,"lat":24.153278,"lon":120.666368,"t":1794377080000,"rx":1794377115919},{"id":"EAR-1015","dir":0,"lat":24.2251,"lon":120.568934,"t":1794377079000,"rx":1794377115919},{"id":"EAR-1017","dir":0,"lat":24.140276,"lon":120.681988,"t":1794377089000,"rx":1794377115919},{"id":"FAE-800","dir":0,"lat":24.240926,"lon":120.564236,"t":1794377082000,"rx":1794377115919},{"id":"763-U8","dir":1,"lat":24.140326,"lon":120.687773,"t":1794377104000,"rx":1794377127426},{"id":"780-U8","dir":0,"lat":24.219966,"lon":120.579946,"t":1794377096000,"rx":1794377127426},{"id":"782-U8","dir":0,"lat":24.294139,"lon":120.589008,"t":1794377100000,"rx":1794377127426},{"id":"EAL-1378","dir":0,"lat":24.160671,"lon":120.652896,"t":1794377102000,"rx":1794377127426},{"id":"EAL-1589","dir":0,"lat":24.246931,"lon":120.574423,"t":1794377092000,"rx":1794377127426},{"id":"EAL-2225","dir":0,"lat":24.18205,"lon":120.595993,"t":1794377094000,"rx":1794377127426},{"id":"EAL-2265","dir":1,"lat":24.226521,"lon":120.576613,"t":1794377094000,"rx":1794377127426},{"id":"EAL-2269","dir":1,"lat":24.24791,"lon":120.575074,"t":1794377105000,"rx":1794377127426},{"id":"EAL-5358","dir":1,"lat":24.165874,"lon":120.643961,"t":1794377095000,"rx":1794377127426},{"id":"EAL-5365","dir":0,"lat":24.23251,"lon":120.572306,"t":1794377104000,"rx":1794377127426},{"id":"EAL-5390","dir":1,"lat":24.204351,"lon":120.581325,"t":1794377104000,"rx":1794377127426},{"id":"EAL-6027","dir":0,"lat":24.264829,"lon":120.541008,"t":1794377102000,"rx":1794377127426},{"id":"EAL-6028","dir":1,"lat":24.165896,"lon":120.644094,"t":1794377097000,"rx":1794377127426},{"id":"EAR-1003","dir":0,"lat":24.153148,"lon":120.666496,"t":1794377100000,"rx":1794377127426},{"id":"EAR-1015","dir":0,"lat":24.2251,"lon":120.568951,"t":1794377099000,"rx":1794377127426},{"id":"FAE-800","dir":0,"lat":24.240871,"lon":120.56435,"t":1794377102000,"rx":1794377127426},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.522088,"t":1794377128000,"rx":1794377151847},{"id":"763-U8","dir":1,"lat":24.140326,"lon":120.686626,"t":1794377124000,"rx":1794377151847},{"id":"780-U8","dir":0,"lat":24.219886,"lon":120.580013,"t":1794377116000,"rx":1794377151847},{"id":"782-U8","dir":0,"lat":24.294139,"lon":120.589023,"t":1794377120000,"rx":1794377151847},{"id":"EAL-1378","dir":0,"lat":24.160671,"lon":120.652896,"t":1794377122000,"rx":1794377151847},{"id":"EAL-1589","dir":0,"lat":24.246611,"lon":120.574256,"t":1794377132000,"rx":1794377151847},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564176,"t":1794377129000,"rx":1794377151847},{"id":"EAL-2225","dir":0,"lat":24.182289,"lon":120.600741,"t":1794377134000,"rx":1794377151847},{"id":"EAL-2263","dir":1,"lat":24.208778,"lon":120.581586,"t":1794377126000,"rx":1794377151847},{"id":"EAL-2265","dir":1,"lat":24.226514,"lon":120.576594,"t":1794377134000,"rx":1794377151847},{"id":"EAL-2269","dir":1,"lat":24.247896,"lon":120.575069,"t":1794377125000,"rx":1794377151847},{"id":"EAL-5358","dir":1,"lat":24.16588,"lon":120.643938,"t":1794377115000,"rx":1794377151847},{"id":"EAL-5365","dir":0,"lat":24.232508,"lon":120.572306,"t":1794377124000,"rx":1794377151847},{"id":"EAL-5390","dir":1,"lat":24.207943,"lon":120.581646,"t":1794377128000,"rx":1794377151847},{"id":"EAL-6027","dir":0,"lat":24.264816,"lon":120.541003,"t":1794377122000,"rx":1794377151847},{"id":"EAL-6028","dir":1,"lat":24.165923,"lon":120.644144,"t":1794377117000,"rx":1794377151847},{"id":"EAR-1003","dir":0,"lat":24.153146,"lon":120.666503,"t":1794377120000,"rx":1794377151847},{"id":"EAR-1015","dir":0,"lat":24.2251,"lon":120.568934,"t":1794377119000,"rx":1794377151847},{"id":"EAR-1017","dir":0,"lat":24.139836,"lon":120.682601,"t":1794377129000,"rx":1794377151847},{"id":"FAE-800","dir":0,"lat":24.240888,"lon":120.564355,"t":1794377122000,"rx":1794377151847},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.522104,"t":1794377148000,"rx":1794377175464},{"id":"763-U8","dir":1,"lat":24.14111,"lon":120.686,"t":1794377145000,"rx":1794377175464},{"id":"780-U8","dir":0,"lat":24.219896,"lon":120.580039,"t":1794377136000,"rx":1794377175464},{"id":"782-U8","dir":0,"lat":24.294068,"lon":120.588994,"t":1794377140000,"rx":1794377175464},{"id":"EAL-1378","dir":0,"lat":24.160671,"lon":120.652896,"t":1794377142000,"rx":1794377175464},{"id":"EAL-2032","dir":0,"lat":24.240138,"lon":120.564193,"t":1794377149000,"rx":1794377175464},{"id":"EAL-2225","dir":0,"lat":24.182565,"lon":120.60195,"t":1794377145000,"rx":1794377175464},{"id":"EAL-2263","dir":1,"lat":24.209076,"lon":120.581566,"t":1794377146000,"rx":1794377175464},{"id":"EAL-2269","dir":1,"lat":24.2479,"lon":120.575068,"t":1794377145000,"rx":1794377175464},{"id":"EAL-5358","dir":1,"lat":24.165883,"lon":120.64391,"t":1794377135000,"rx":1794377175464},{"id":"EAL-5365","dir":0,"lat":24.232508,"lon":120.572323,"t":1794377144000,"rx":1794377175464},{"id":"EAL-5390","dir":1,"lat":24.208853,"lon":120.581593,"t":1794377144000,"rx":1794377175464},{"id":"EAL-6027","dir":0,"lat":24.264811,"lon":120.541006,"t":1794377142000,"rx":1794377175464},{"id":"EAL-6028","dir":1,"lat":24.165919,"lon":120.64413,"t":1794377137000,"rx":1794377175464},{"id":"EAR-1003","dir":0,"lat":24.153146,"lon":120.66652,"t":1794377140000,"rx":1794377175464},{"id":"EAR-1015","dir":0,"lat":24.2251,"lon":120.568933,"t":1794377139000,"rx":1794377175464},{"id":"EAR-1017","dir":0,"lat":24.138944,"lon":120.683726,"t":1794377149000,"rx":1794377175464},{"id":"FAE-800","dir":0,"lat":24.240909,"lon":120.56434,"t":1794377142000,"rx":1794377175464},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.522088,"t":1794377168000,"rx":1794377199453},{"id":"763-U8","dir":1,"lat":24.142863,"lon":120.685919,"t":1794377164000,"rx":1794377199453},{"id":"780-U8","dir":0,"lat":24.219093,"lon":120.580213,"t":1794377177000,"rx":1794377199453},{"id":"782-U8","dir":0,"lat":24.290214,"lon":120.586794,"t":1794377180000,"rx":1794377199453},{"id":"EAL-1378","dir":0,"lat":24.160671,"lon":120.652896,"t":1794377162000,"rx":1794377199453},{"id":"EAL-1589","dir":0,"lat":24.243673,"lon":120.572356,"t":1794377172000,"rx":1794377199453},{"id":"EAL-2032","dir":0,"lat":24.24014,"lon":120.564176,"t":1794377169000,"rx":1794377199453},{"id":"EAL-2225","dir":0,"lat":24.182876,"lon":120.603341,"t":1794377174000,"rx":1794377199453},{"id":"EAL-2263","dir":1,"lat":24.209773,"lon":120.581495,"t":1794377170000,"rx":1794377199453},{"id":"EAL-2265","dir":1,"lat":24.226514,"lon":120.576594,"t":1794377174000,"rx":1794377199453},{"id":"EAL-2269","dir":1,"lat":24.247904,"lon":120.575079,"t":1794377165000,"rx":1794377199453},{"id":"EAL-5358","dir":1,"lat":24.166163,"lon":120.643469,"t":1794377175000,"rx":1794377199453},{"id":"EAL-5365","dir":0,"lat":24.232528,"lon":120.572313,"t":1794377164000,"rx":1794377199453},{"id":"EAL-5390","dir":1,"lat":24.209843,"lon":120.581481,"t":1794377175000,"rx":1794377199453},{"id":"EAL-6027","dir":0,"lat":24.264813,"lon":120.540994,"t":1794377162000,"rx":1794377199453},{"id":"EAL-6028","dir":1,"lat":24.16609,"lon":120.643808,"t":1794377177000,"rx":1794377199453},{"id":"EAR-1003","dir":0,"lat":24.153151,"lon":120.666516,"t":1794377180000,"rx":1794377199453},{"id":"EAR-1015","dir":0,"lat":24.225098,"lon":120.568931,"t":1794377179000,"rx":1794377199453},{"id":"EAR-1017","dir":0,"lat":24.138648,"lon":120.683904,"t":1794377172000,"rx":1794377199453},{"id":"FAE-800","dir":0,"lat":24.240901,"lon":120.564313,"t":1794377162000,"rx":1794377199453},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.522104,"t":1794377188000,"rx":1794377223419},{"id":"763-U8","dir":1,"lat":24.143103,"lon":120.685253,"t":1794377184000,"rx":1794377223419},{"id":"780-U8","dir":0,"lat":24.217826,"lon":120.580373,"t":1794377190000,"rx":1794377223419},{"id":"EAL-1378","dir":0,"lat":24.160658,"lon":120.652914,"t":1794377182000,"rx":1794377223419},{"id":"EAL-1589","dir":0,"lat":24.243333,"lon":120.572091,"t":1794377192000,"rx":1794377223419},{"id":"EAL-2032","dir":0,"lat":24.24014,"lon":120.564178,"t":1794377189000,"rx":1794377223419},{"id":"EAL-2225","dir":0,"lat":24.182888,"lon":120.603364,"t":1794377194000,"rx":1794377223419},{"id":"EAL-2263","dir":1,"lat":24.212306,"lon":120.581196,"t":1794377193000,"rx":1794377223419},{"id":"EAL-2265","dir":1,"lat":24.226513,"lon":120.576593,"t":1794377194000,"rx":1794377223419},{"id":"EAL-2269","dir":1,"lat":24.247904,"lon":120.575081,"t":1794377185000,"rx":1794377223419},{"id":"EAL-5358","dir":1,"lat":24.167581,"lon":120.641183,"t":1794377195000,"rx":1794377223419},{"id":"EAL-5365","dir":0,"lat":24.232526,"lon":120.572313,"t":1794377184000,"rx":1794377223419},{"id":"EAL-5390","dir":1,"lat":24.212106,"lon":120.581208,"t":1794377195000,"rx":1794377223419},{"id":"EAL-6027","dir":0,"lat":24.264813,"lon":120.541011,"t":1794377182000,"rx":1794377223419},{"id":"EAL-6028","dir":1,"lat":24.166756,"lon":120.642515,"t":1794377190000,"rx":1794377223419},{"id":"EAR-1017","dir":0,"lat":24.138168,"lon":120.68434,"t":1794377189000,"rx":1794377223419},{"id":"FAE-800","dir":0,"lat":24.240898,"lon":120.564313,"t":1794377182000,"rx":1794377223419},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.52208,"t":1794377208000,"rx":1794377247399},{"id":"763-U8","dir":1,"lat":24.141463,"lon":120.683546,"t":1794377224000,"rx":1794377247399},{"id":"780-U8","dir":0,"lat":24.217463,"lon":120.580386,"t":1794377216000,"rx":1794377247399},{"id":"782-U8","dir":0,"lat":24.287908,"lon":120.585496,"t":1794377220000,"rx":1794377247399},{"id":"EAL-1378","dir":0,"lat":24.159203,"lon":120.655598,"t":1794377222000,"rx":1794377247399},{"id":"EAL-1589","dir":0,"lat":24.24161,"lon":120.570949,"t":1794377212000,"rx":1794377247399},{"id":"EAL-2032","dir":0,"lat":24.24014,"lon":120.564194,"t":1794377209000,"rx":1794377247399},{"id":"EAL-2225","dir":0,"lat":24.182958,"lon":120.603605,"t":1794377214000,"rx":1794377247399},{"id":"EAL-2263","dir":1,"lat":24.215816,"lon":120.580791,"t":1794377217000,"rx":1794377247399},{"id":"EAL-2265","dir":1,"lat":24.226514,"lon":120.576593,"t":1794377214000,"rx":1794377247399},{"id":"EAL-2269","dir":1,"lat":24.247896,"lon":120.575093,"t":1794377225000,"rx":1794377247399},{"id":"EAL-5358","dir":1,"lat":24.168911,"lon":120.639558,"t":1794377215000,"rx":1794377247399},{"id":"EAL-5365","dir":0,"lat":24.232516,"lon":120.572328,"t":1794377224000,"rx":1794377247399},{"id":"EAL-5390","dir":1,"lat":24.216246,"lon":120.580699,"t":1794377224000,"rx":1794377247399},{"id":"EAL-6027","dir":0,"lat":24.264849,"lon":120.540998,"t":1794377222000,"rx":1794377247399},{"id":"EAL-6028","dir":1,"lat":24.168841,"lon":120.639849,"t":1794377224000,"rx":1794377247399},{"id":"EAR-1003","dir":0,"lat":24.15212,"lon":120.66803,"t":1794377221000,"rx":1794377247399},{"id":"EAR-1015","dir":0,"lat":24.225111,"lon":120.568906,"t":1794377219000,"rx":1794377247399},{"id":"EAR-1017","dir":0,"lat":24.138161,"lon":120.684306,"t":1794377209000,"rx":1794377247399},{"id":"FAE-800","dir":0,"lat":24.240935,"lon":120.564246,"t":1794377222000,"rx":1794377247399},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522086,"t":1794377248000,"rx":1794377271436},{"id":"763-U8","dir":1,"lat":24.14048,"lon":120.682573,"t":1794377244000,"rx":1794377271436},{"id":"780-U8","dir":0,"lat":24.214376,"lon":120.580746,"t":1794377250000,"rx":1794377271436},{"id":"782-U8","dir":0,"lat":24.287908,"lon":120.585513,"t":1794377240000,"rx":1794377271436},{"id":"EAL-1378","dir":0,"lat":24.158908,"lon":120.65621,"t":1794377253000,"rx":1794377271436},{"id":"EAL-1589","dir":0,"lat":24.238796,"lon":120.569443,"t":1794377252000,"rx":1794377271436},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564178,"t":1794377249000,"rx":1794377271436},{"id":"EAL-2225","dir":0,"lat":24.183603,"lon":120.606413,"t":1794377254000,"rx":1794377271436},{"id":"EAL-2263","dir":1,"lat":24.21692,"lon":120.580624,"t":1794377246000,"rx":1794377271436},{"id":"EAL-2265","dir":1,"lat":24.226516,"lon":120.576596,"t":1794377254000,"rx":1794377271436},{"id":"EAL-2269","dir":1,"lat":24.24789,"lon":120.575096,"t":1794377245000,"rx":1794377271436},{"id":"EAL-5358","dir":1,"lat":24.16927,"lon":120.639328,"t":1794377237000,"rx":1794377271436},{"id":"EAL-5365","dir":0,"lat":24.232514,"lon":120.572328,"t":1794377244000,"rx":1794377271436},{"id":"EAL-5390","dir":1,"lat":24.216725,"lon":120.580606,"t":1794377244000,"rx":1794377271436},{"id":"EAL-6027","dir":0,"lat":24.264836,"lon":120.541008,"t":1794377242000,"rx":1794377271436},{"id":"EAL-6028","dir":1,"lat":24.169251,"lon":120.639259,"t":1794377247000,"rx":1794377271436},{"id":"EAR-1003","dir":0,"lat":24.149999,"lon":120.670756,"t":1794377246000,"rx":1794377271436},{"id":"EAR-1015","dir":0,"lat":24.225111,"lon":120.568905,"t":1794377239000,"rx":1794377271436},{"id":"EAR-1017","dir":0,"lat":24.138161,"lon":120.684325,"t":1794377249000,"rx":1794377271436},{"id":"FAE-800","dir":0,"lat":24.240935,"lon":120.564234,"t":1794377242000,"rx":1794377271436},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522069,"t":1794377268000,"rx":1794377308017},{"id":"763-U8","dir":1,"lat":24.141756,"lon":120.680453,"t":1794377284000,"rx":1794377308017},{"id":"780-U8","dir":0,"lat":24.210399,"lon":120.581239,"t":1794377281000,"rx":1794377308017},{"id":"782-U8","dir":0,"lat":24.286198,"lon":120.584546,"t":1794377280000,"rx":1794377308017},{"id":"EAL-1378","dir":0,"lat":24.15818,"lon":120.657534,"t":1794377282000,"rx":1794377308017},{"id":"EAL-1589","dir":0,"lat":24.238251,"lon":120.56941,"t":1794377272000,"rx":1794377308017},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564179,"t":1794377269000,"rx":1794377308017},{"id":"EAL-2225","dir":0,"lat":24.184181,"lon":120.608883,"t":1794377274000,"rx":1794377308017},{"id":"EAL-2263","dir":1,"lat":24.219401,"lon":120.580326,"t":1794377272000,"rx":1794377308017},{"id":"EAL-2265","dir":1,"lat":24.226516,"lon":120.576613,"t":1794377274000,"rx":1794377308017},{"id":"EAL-2269","dir":1,"lat":24.24789,"lon":120.575098,"t":1794377285000,"rx":1794377308017},{"id":"EAL-5358","dir":1,"lat":24.172628,"lon":120.634491,"t":1794377275000,"rx":1794377308017},{"id":"EAL-5365","dir":0,"lat":24.232506,"lon":120.57231,"t":1794377284000,"rx":1794377308017},{"id":"EAL-5390","dir":1,"lat":24.219456,"lon":120.58028,"t":1794377284000,"rx":1794377308017},{"id":"EAL-6027","dir":0,"lat":24.264851,"lon":120.541004,"t":1794377282000,"rx":1794377308017},{"id":"EAL-6028","dir":1,"lat":24.171426,"lon":120.636343,"t":1794377277000,"rx":1794377308017},{"id":"EAR-1003","dir":0,"lat":24.149161,"lon":120.671756,"t":1794377280000,"rx":1794377308017},{"id":"EAR-1015","dir":0,"lat":24.22511,"lon":120.568921,"t":1794377279000,"rx":1794377308017},{"id":"EAR-1017","dir":0,"lat":24.137543,"lon":120.685136,"t":1794377275000,"rx":1794377308017},{"id":"FAE-800","dir":0,"lat":24.240936,"lon":120.564231,"t":1794377282000,"rx":1794377308017},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522086,"t":1794377288000,"rx":1794377331467},{"id":"780-U8","dir":0,"lat":24.210116,"lon":120.581253,"t":1794377296000,"rx":1794377331467},{"id":"782-U8","dir":0,"lat":24.283401,"lon":120.582939,"t":1794377300000,"rx":1794377331467},{"id":"EAL-1589","dir":0,"lat":24.238251,"lon":120.56941,"t":1794377292000,"rx":1794377331467},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564196,"t":1794377289000,"rx":1794377331467},{"id":"EAL-2225","dir":0,"lat":24.184373,"lon":120.609991,"t":1794377294000,"rx":1794377331467},{"id":"EAL-2263","dir":1,"lat":24.219521,"lon":120.580301,"t":1794377286000,"rx":1794377331467},{"id":"EAL-2265","dir":1,"lat":24.226516,"lon":120.576594,"t":1794377294000,"rx":1794377331467},{"id":"EAL-5358","dir":1,"lat":24.174855,"lon":120.631623,"t":1794377295000,"rx":1794377331467},{"id":"EAL-6028","dir":1,"lat":24.173076,"lon":120.634116,"t":1794377297000,"rx":1794377331467},{"id":"EAR-1003","dir":0,"lat":24.149128,"lon":120.671771,"t":1794377300000,"rx":1794377331467},{"id":"EAR-1015","dir":0,"lat":24.22511,"lon":120.568903,"t":1794377299000,"rx":1794377331467},{"id":"EAR-1017","dir":0,"lat":24.137813,"lon":120.686589,"t":1794377294000,"rx":1794377331467},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522069,"t":1794377308000,"rx":1794377343439},{"id":"763-U8","dir":1,"lat":24.142046,"lon":120.680093,"t":1794377304000,"rx":1794377343439},{"id":"EAL-1378","dir":0,"lat":24.15818,"lon":120.657534,"t":1794377302000,"rx":1794377343439},{"id":"EAL-1589","dir":0,"lat":24.238251,"lon":120.56941,"t":1794377312000,"rx":1794377343439},{"id":"EAL-2032","dir":0,"lat":24.240141,"lon":120.564179,"t":1794377309000,"rx":1794377343439},{"id":"EAL-2225","dir":0,"lat":24.184439,"lon":120.610288,"t":1794377314000,"rx":1794377343439},{"id":"EAL-2263","dir":1,"lat":24.219521,"lon":120.580318,"t":1794377306000,"rx":1794377343439},{"id":"EAL-2265","dir":1,"lat":24.226518,"lon":120.576593,"t":1794377314000,"rx":1794377343439},{"id":"EAL-2269","dir":1,"lat":24.247888,"lon":120.575098,"t":1794377305000,"rx":1794377343439},{"id":"EAL-5358","dir":1,"lat":24.176736,"lon":120.628311,"t":1794377315000,"rx":1794377343439},{"id":"EAL-5365","dir":0,"lat":24.232506,"lon":120.572311,"t":1794377304000,"rx":1794377343439},{"id":"EAL-5390","dir":1,"lat":24.219398,"lon":120.580295,"t":1794377304000,"rx":1794377343439},{"id":"EAL-6027","dir":0,"lat":24.264831,"lon":120.541011,"t":1794377302000,"rx":1794377343439},{"id":"EAL-6028","dir":1,"lat":24.174568,"lon":120.632234,"t":1794377315000,"rx":1794377343439},{"id":"EAR-1017","dir":0,"lat":24.137753,"lon":120.686638,"t":1794377309000,"rx":1794377343439},{"id":"FAE-800","dir":0,"lat":24.240936,"lon":120.564229,"t":1794377302000,"rx":1794377343439},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522086,"t":1794377328000,"rx":1794377356334},{"id":"763-U8","dir":1,"lat":24.142403,"lon":120.679626,"t":1794377324000,"rx":1794377356334},{"id":"780-U8","dir":0,"lat":24.209986,"lon":120.58128,"t":1794377316000,"rx":1794377356334},{"id":"782-U8","dir":0,"lat":24.282313,"lon":120.581811,"t":1794377320000,"rx":1794377356334},{"id":"EAL-1378","dir":0,"lat":24.15818,"lon":120.657534,"t":1794377322000,"rx":1794377356334},{"id":"EAL-2032","dir":0,"lat":24.240143,"lon":120.564179,"t":1794377329000,"rx":1794377356334},{"id":"EAL-2225","dir":0,"lat":24.184525,"lon":120.610723,"t":1794377317000,"rx":1794377356334},{"id":"EAL-2263","dir":1,"lat":24.219521,"lon":120.580301,"t":1794377326000,"rx":1794377356334},{"id":"EAL-2269","dir":1,"lat":24.247888,"lon":120.5751,"t":1794377325000,"rx":1794377356334},{"id":"EAL-5365","dir":0,"lat":24.232518,"lon":120.572315,"t":1794377324000,"rx":1794377356334},{"id":"EAL-5390","dir":1,"lat":24.219398,"lon":120.580293,"t":1794377324000,"rx":1794377356334},{"id":"EAL-6027","dir":0,"lat":24.264831,"lon":120.541028,"t":1794377322000,"rx":1794377356334},{"id":"EAL-6028","dir":1,"lat":24.175726,"lon":120.630628,"t":1794377328000,"rx":1794377356334},{"id":"EAR-1003","dir":0,"lat":24.148818,"lon":120.672139,"t":1794377322000,"rx":1794377356334},{"id":"EAR-1015","dir":0,"lat":24.22511,"lon":120.56892,"t":1794377319000,"rx":1794377356334},{"id":"EAR-1017","dir":0,"lat":24.137754,"lon":120.686639,"t":1794377329000,"rx":1794377356334},{"id":"FAE-800","dir":0,"lat":24.240936,"lon":120.564231,"t":1794377322000,"rx":1794377356334},{"id":"762-U8","dir":0,"lat":24.259166,"lon":120.522069,"t":1794377348000,"rx":1794377379528},{"id":"763-U8","dir":1,"lat":24.143533,"lon":120.67852,"t":1794377348000,"rx":1794377379528},{"id":"780-U8","dir":0,"lat":24.209033,"lon":120.5814,"t":1794377356000,"rx":1794377379528},{"id":"782-U8","dir":0,"lat":24.280985,"lon":120.581123,"t":1794377360000,"rx":1794377379528},{"id":"EAL-1378","dir":0,"lat":24.157924,"lon":120.657989,"t":1794377359000,"rx":1794377379528},{"id":"EAL-1589","dir":0,"lat":24.235643,"lon":120.570413,"t":1794377352000,"rx":1794377379528},{"id":"EAL-2032","dir":0,"lat":24.240143,"lon":120.564181,"t":1794377349000,"rx":1794377379528},{"id":"EAL-2225","dir":0,"lat":24.183336,"lon":120.614663,"t":1794377354000,"rx":1794377379528},{"id":"EAL-2263","dir":1,"lat":24.220478,"lon":120.580044,"t":1794377358000,"rx":1794377379528},{"id":"EAL-2265","dir":1,"lat":24.226518,"lon":120.576593,"t":1794377354000,"rx":1794377379528},{"id":"EAL-2269","dir":1,"lat":24.247888,"lon":120.575116,"t":1794377345000,"rx":1794377379528},{"id":"EAL-5358","dir":1,"lat":24.179288,"lon":120.623405,"t":1794377355000,"rx":1794377379528},{"id":"EAL-5365","dir":0,"lat":24.23252,"lon":120.572315,"t":1794377344000,"rx":1794377379528},{"id":"EAL-5390","dir":1,"lat":24.219386,"lon":120.580303,"t":1794377344000,"rx":1794377379528},{"id":"EAL-6027","dir":0,"lat":24.264818,"lon":120.541013,"t":1794377342000,"rx":1794377379528},{"id":"EAL-6028","dir":1,"lat":24.177468,"lon":120.627006,"t":1794377357000,"rx":1794377379528},{"id":"EAR-1003","dir":0,"lat":24.147438,"lon":120.673696,"t":1794377360000,"rx":1794377379528},{"id":"EAR-1015","dir":0,"lat":24.225108,"lon":120.568903,"t":1794377359000,"rx":1794377379528},{"id":"EAR-1017","dir":0,"lat":24.137761,"lon":120.686639,"t":1794377349000,"rx":1794377379528},{"id":"FAE-800","dir":0,"lat":24.240935,"lon":120.564233,"t":1794377342000,"rx":1794377379528},{"id":"762-U8","dir":0,"lat":24.259161,"lon":120.522075,"t":1794377368000,"rx":1794377392030},{"id":"763-U8","dir":1,"lat":24.143766,"lon":120.678253,"t":1794377364000,"rx":1794377392030},{"id":"EAL-1378","dir":0,"lat":24.157351,"lon":120.659006,"t":1794377373000,"rx":1794377392030},{"id":"EAL-1589","dir":0,"lat":24.233861,"lon":120.571521,"t":1794377372000,"rx":1794377392030},{"id":"EAL-2032","dir":0,"lat":24.240143,"lon":120.564198,"t":1794377369000,"rx":1794377392030},{"id":"EAL-2225","dir":0,"lat":24.182298,"lon":120.61656,"t":1794377374000,"rx":1794377392030},{"id":"EAL-2263","dir":1,"lat":24.220935,"lon":120.579858,"t":1794377366000,"rx":1794377392030},{"id":"EAL-2265","dir":1,"lat":24.226518,"lon":120.57661,"t":1794377374000,"rx":1794377392030},{"id":"EAL-2269","dir":1,"lat":24.247879,"lon":120.575104,"t":1794377365000,"rx":1794377392030},{"id":"EAL-5365","dir":0,"lat":24.23252,"lon":120.572331,"t":1794377364000,"rx":1794377392030},{"id":"EAL-5390","dir":1,"lat":24.220639,"lon":120.579951,"t":1794377364000,"rx":1794377392030},{"id":"EAL-6027","dir":0,"lat":24.264803,"lon":120.541041,"t":1794377362000,"rx":1794377392030},{"id":"EAL-6028","dir":1,"lat":24.178439,"lon":120.625083,"t":1794377372000,"rx":1794377392030},{"id":"EAR-1003","dir":0,"lat":24.146969,"lon":120.674144,"t":1794377373000,"rx":1794377392030},{"id":"EAR-1017","dir":0,"lat":24.138124,"lon":120.687584,"t":1794377372000,"rx":1794377392030},{"id":"FAE-800","dir":0,"lat":24.240933,"lon":120.564233,"t":1794377362000,"rx":1794377392030}],"boardingEta":[{"t":1794376157891,"eta":null,"arriving":false},{"t":1794376168307,"eta":null,"arriving":false},{"t":1794376180028,"eta":null,"arriving":false},{"t":1794376192545,"eta":null,"arriving":false},{"t":1794376205665,"eta":null,"arriving":false},{"t":1794376216460,"eta":null,"arriving":false},{"t":1794376230308,"eta":null,"arriving":false},{"t":1794376240385,"eta":null,"arriving":false},{"t":1794376253472,"eta":null,"arriving":false},{"t":1794376264176,"eta":null,"arriving":false},{"t":1794376275956,"eta":null,"arriving":false},{"t":1794376289363,"eta":null,"arriving":false},{"t":1794376299970,"eta":null,"arriving":false},{"t":1794376312067,"eta":null,"arriving":false},{"t":1794376325176,"eta":null,"arriving":false},{"t":1794376336180,"eta":null,"arriving":false},{"t":1794376348852,"eta":null,"arriving":false},{"t":1794376360375,"eta":null,"arriving":false},{"t":1794376373564,"eta":21,"arriving":false},{"t":1794376384381,"eta":21,"arriving":false},{"t":1794376396416,"eta":22,"arriving":false},{"t":1794376410169,"eta":-1,"arriving":false},{"t":1794376420134,"eta":-1,"arriving":false},{"t":1794376433078,"eta":-1,"arriving":true},{"t":1794376443952,"eta":-1,"arriving":true},{"t":1794376457703,"eta":-1,"arriving":true},{"t":1794376468193,"eta":-1,"arriving":true},{"t":1794376481353,"eta":-1,"arriving":true},{"t":1794376492105,"eta":-1,"arriving":true},{"t":1794376505825,"eta":null,"arriving":false},{"t":1794376516171,"eta":null,"arriving":false},{"t":1794376528793,"eta":null,"arriving":false},{"t":1794376540048,"eta":null,"arriving":false},{"t":1794376554138,"eta":null,"arriving":false},{"t":1794376564028,"eta":null,"arriving":false},{"t":1794376577304,"eta":null,"arriving":false},{"t":1794376588383,"eta":null,"arriving":false},{"t":1794376601020,"eta":null,"arriving":false},{"t":1794376612031,"eta":null,"arriving":false},{"t":1794376625360,"eta":null,"arriving":false},{"t":1794376636183,"eta":null,"arriving":false},{"t":1794376649710,"eta":null,"arriving":false},{"t":1794376660166,"eta":null,"arriving":false},{"t":1794376672882,"eta":null,"arriving":false},{"t":1794376684282,"eta":null,"arriving":false},{"t":1794376697156,"eta":null,"arriving":false},{"t":1794376708165,"eta":null,"arriving":false},{"t":1794376720806,"eta":null,"arriving":false},{"t":1794376732157,"eta":null,"arriving":false},{"t":1794376743961,"eta":null,"arriving":false},{"t":1794376757487,"eta":null,"arriving":false},{"t":1794376767988,"eta":null,"arriving":false},{"t":1794376782505,"eta":null,"arriving":false},{"t":1794376792050,"eta":null,"arriving":false},{"t":1794376805398,"eta":null,"arriving":false},{"t":1794376816206,"eta":null,"arriving":false},{"t":1794376829467,"eta":null,"arriving":false},{"t":1794376840451,"eta":null,"arriving":false},{"t":1794376853444,"eta":null,"arriving":false},{"t":1794376864233,"eta":null,"arriving":false},{"t":1794376877381,"eta":null,"arriving":false},{"t":1794376888188,"eta":null,"arriving":false},{"t":1794376901048,"eta":null,"arriving":false},{"t":1794376912091,"eta":null,"arriving":false},{"t":1794376925179,"eta":null,"arriving":false},{"t":1794376936264,"eta":null,"arriving":false},{"t":1794376948451,"eta":null,"arriving":false},{"t":1794376961266,"eta":null,"arriving":false},{"t":1794376972217,"eta":null,"arriving":false},{"t":1794376985520,"eta":null,"arriving":false},{"t":1794376996187,"eta":null,"arriving":false},{"t":1794377009040,"eta":null,"arriving":false},{"t":1794377020518,"eta":null,"arriving":false},{"t":1794377033597,"eta":null,"arriving":false},{"t":1794377044560,"eta":null,"arriving":false},{"t":1794377057606,"eta":38,"arriving":false},{"t":1794377068529,"eta":38,"arriving":false},{"t":1794377081509,"eta":-1,"arriving":false},{"t":1794377092556,"eta":-1,"arriving":false},{"t":1794377105161,"eta":-1,"arriving":false},{"t":1794377116553,"eta":-1,"arriving":false},{"t":1794377129515,"eta":null,"arriving":false},{"t":1794377140010,"eta":null,"arriving":false},{"t":1794377153037,"eta":null,"arriving":false},{"t":1794377164074,"eta":null,"arriving":false},{"t":1794377177664,"eta":null,"arriving":false},{"t":1794377188632,"eta":null,"arriving":false},{"t":1794377201503,"eta":null,"arriving":false},{"t":1794377212320,"eta":null,"arriving":false},{"t":1794377225545,"eta":null,"arriving":false},{"t":1794377236520,"eta":null,"arriving":false},{"t":1794377249137,"eta":null,"arriving":false},{"t":1794377260505,"eta":null,"arriving":false},{"t":1794377273243,"eta":null,"arriving":false},{"t":1794377284664,"eta":null,"arriving":false},{"t":1794377295967,"eta":null,"arriving":false},{"t":1794377310531,"eta":null,"arriving":false},{"t":1794377319987,"eta":null,"arriving":false},{"t":1794377332085,"eta":null,"arriving":false},{"t":1794377344070,"eta":null,"arriving":false},{"t":1794377358647,"eta":null,"arriving":false},{"t":1794377368335,"eta":null,"arriving":false},{"t":1794377380140,"eta":null,"arriving":false},{"t":1794377392918,"eta":null,"arriving":false},{"t":1794377404002,"eta":null,"arriving":false}],"riderFixes":[{"t":1794376156000,"lat":24.222015,"lon":120.579226,"acc":15.0,"spd":0.0},{"t":1794376161000,"lat":24.22169,"lon":120.579114,"acc":15.0,"spd":0.3},{"t":1794376166000,"lat":24.221746,"lon":120.579206,"acc":15.0,"spd":0.6},{"t":1794376171000,"lat":24.222095,"lon":120.578895,"acc":15.0,"spd":0.0},{"t":1794376176000,"lat":24.221627,"lon":120.579063,"acc":15.0,"spd":0.5},{"t":1794376181000,"lat":24.221959,"lon":120.579051,"acc":15.0,"spd":0.0},{"t":1794376186000,"lat":24.221683,"lon":120.579105,"acc":15.0,"spd":0.5},{"t":1794376191000,"lat":24.221945,"lon":120.579093,"acc":15.0,"spd":0.0},{"t":1794376196000,"lat":24.221825,"lon":120.578959,"acc":15.0,"spd":0.0},{"t":1794376201000,"lat":24.222104,"lon":120.57923,"acc":15.0,"spd":0.0},{"t":1794376206000,"lat":24.221771,"lon":120.578809,"acc":15.0,"spd":0.0},{"t":1794376211000,"lat":24.221767,"lon":120.578949,"acc":15.0,"spd":0.0},{"t":1794376216000,"lat":24.221952,"lon":120.579289,"acc":15.0,"spd":0.0},{"t":1794376221000,"lat":24.22201,"lon":120.579153,"acc":15.0,"spd":0.0},{"t":1794376226000,"lat":24.221778,"lon":120.5791,"acc":15.0,"spd":0.0},{"t":1794376231000,"lat":24.221972,"lon":120.579093,"acc":15.0,"spd":0.0},{"t":1794376236000,"lat":24.221854,"lon":120.579045,"acc":15.0,"spd":0.0},{"t":1794376241000,"lat":24.222073,"lon":120.579087,"acc":15.0,"spd":0.4},{"t":1794376246000,"lat":24.222087,"lon":120.579125,"acc":15.0,"spd":0.0},{"t":1794376251000,"lat":24.221974,"lon":120.579164,"acc":15.0,"spd":0.0},{"t":1794376256000,"lat":24.22192,"lon":120.57906,"acc":15.0,"spd":0.5},{"t":1794376261000,"lat":24.222036,"lon":120.579176,"acc":15.0,"spd":0.0},{"t":1794376266000,"lat":24.221838,"lon":120.578983,"acc":15.0,"spd":0.0},{"t":1794376271000,"lat":24.221715,"lon":120.57889,"acc":15.0,"spd":0.0},{"t":1794376276000,"lat":24.221953,"lon":120.579192,"acc":15.0,"spd":0.1},{"t":1794376281000,"lat":24.221899,"lon":120.579062,"acc":15.0,"spd":0.0},{"t":1794376286000,"lat":24.222034,"lon":120.579153,"acc":15.0,"spd":0.0},{"t":1794376291000,"lat":24.221665,"lon":120.579027,"acc":15.0,"spd":0.0},{"t":1794376296000,"lat":24.221779,"lon":120.579244,"acc":15.0,"spd":0.0},{"t":1794376301000,"lat":24.221898,"lon":120.579281,"acc":15.0,"spd":0.0},{"t":1794376306000,"lat":24.221815,"lon":120.579096,"acc":15.0,"spd":0.0},{"t":1794376311000,"lat":24.221643,"lon":120.579267,"acc":15.0,"spd":0.0},{"t":1794376316000,"lat":24.221908,"lon":120.578998,"acc":15.0,"spd":0.4},{"t":1794376321000,"lat":24.221922,"lon":120.579029,"acc":15.0,"spd":0.0},{"t":1794376326000,"lat":24.222079,"lon":120.579006,"acc":15.0,"spd":0.6},{"t":1794376331000,"lat":24.221825,"lon":120.57917,"acc":15.0,"spd":0.0},{"t":1794376336000,"lat":24.221908,"lon":120.578938,"acc":15.0,"spd":0.8},{"t":1794376341000,"lat":24.22173,"lon":120.579095,"acc":15.0,"spd":0.0},{"t":1794376346000,"lat":24.221943,"lon":120.579119,"acc":15.0,"spd":0.7},{"t":1794376351000,"lat":24.22178,"lon":120.579024,"acc":15.0,"spd":0.5},{"t":1794376356000,"lat":24.221953,"lon":120.578978,"acc":15.0,"spd":0.1},{"t":1794376361000,"lat":24.221964,"lon":120.579085,"acc":15.0,"spd":0.4},{"t":1794376366000,"lat":24.221861,"lon":120.578956,"acc":15.0,"spd":0.0},{"t":1794376371000,"lat":24.221854,"lon":120.579129,"acc":15.0,"spd":0.0},{"t":1794376376000,"lat":24.221967,"lon":120.579151,"acc":15.0,"spd":3.4},{"t":1794376381000,"lat":24.221517,"lon":120.579274,"acc":15.0,"spd":0.1},{"t":1794376386000,"lat":24.2218,"lon":120.579477,"acc":15.0,"spd":0.3},{"t":1794376391000,"lat":24.221875,"lon":120.579067,"acc":15.0,"spd":0.0},{"t":1794376396000,"lat":24.221513,"lon":120.579287,"acc":15.0,"spd":0.8},{"t":1794376401000,"lat":24.221643,"lon":120.579335,"acc":15.0,"spd":0.7},{"t":1794376406000,"lat":24.221813,"lon":120.579108,"acc":15.0,"spd":0.9},{"t":1794376411000,"lat":24.221872,"lon":120.579148,"acc":15.0,"spd":0.1},{"t":1794376416000,"lat":24.221543,"lon":120.578979,"acc":15.0,"spd":0.6},{"t":1794376421000,"lat":24.221601,"lon":120.579596,"acc":15.0,"spd":1.8},{"t":1794376426000,"lat":24.221518,"lon":120.579322,"acc":15.0,"spd":2.4},{"t":1794376431000,"lat":24.221453,"lon":120.579476,"acc":15.0,"spd":1.9},{"t":1794376436000,"lat":24.221471,"lon":120.579525,"acc":15.0,"spd":1.7},{"t":1794376441000,"lat":24.221177,"lon":120.579325,"acc":15.0,"spd":1.9},{"t":1794376446000,"lat":24.221307,"lon":120.579357,"acc":15.0,"spd":1.5},{"t":1794376451000,"lat":24.22124,"lon":120.579288,"acc":15.0,"spd":1.5},{"t":1794376456000,"lat":24.220859,"lon":120.579423,"acc":15.0,"spd":5.7},{"t":1794376461000,"lat":24.220399,"lon":120.579739,"acc":15.0,"spd":11.3},{"t":1794376466000,"lat":24.220013,"lon":120.580051,"acc":15.0,"spd":11.8},{"t":1794376471000,"lat":24.219451,"lon":120.580048,"acc":15.0,"spd":11.7},{"t":1794376476000,"lat":24.219149,"lon":120.580063,"acc":15.0,"spd":10.1},{"t":1794376481000,"lat":24.218369,"lon":120.580153,"acc":15.0,"spd":9.5},{"t":1794376486000,"lat":24.218195,"lon":120.580325,"acc":15.0,"spd":9.8},{"t":1794376491000,"lat":24.217699,"lon":120.580281,"acc":15.0,"spd":9.4},{"t":1794376496000,"lat":24.21728,"lon":120.580355,"acc":15.0,"spd":8.9},{"t":1794376501000,"lat":24.216797,"lon":120.580304,"acc":15.0,"spd":9.1},{"t":1794376506000,"lat":24.216531,"lon":120.580467,"acc":15.0,"spd":8.8},{"t":1794376511000,"lat":24.216303,"lon":120.580599,"acc":15.0,"spd":9.0},{"t":1794376516000,"lat":24.21556,"lon":120.580403,"acc":15.0,"spd":9.0},{"t":1794376521000,"lat":24.215441,"lon":120.580806,"acc":15.0,"spd":9.0},{"t":1794376526000,"lat":24.214964,"lon":120.580556,"acc":15.0,"spd":9.3},{"t":1794376531000,"lat":24.214649,"lon":120.580665,"acc":15.0,"spd":8.4},{"t":1794376536000,"lat":24.214143,"lon":120.580695,"acc":15.0,"spd":11.8},{"t":1794376541000,"lat":24.213312,"lon":120.581019,"acc":15.0,"spd":16.0},{"t":1794376546000,"lat":24.212867,"lon":120.580771,"acc":15.0,"spd":10.9},{"t":1794376551000,"lat":24.212349,"lon":120.580945,"acc":15.0,"spd":11.2},{"t":1794376556000,"lat":24.211971,"lon":120.580912,"acc":15.0,"spd":11.2},{"t":1794376561000,"lat":24.211537,"lon":120.580894,"acc":15.0,"spd":10.8},{"t":1794376566000,"lat":24.211089,"lon":120.581142,"acc":15.0,"spd":10.5},{"t":1794376571000,"lat":24.210463,"lon":120.581282,"acc":15.0,"spd":10.7},{"t":1794376576000,"lat":24.210083,"lon":120.581295,"acc":15.0,"spd":7.2},{"t":1794376581000,"lat":24.20989,"lon":120.581098,"acc":15.0,"spd":1.5},{"t":1794376586000,"lat":24.209792,"lon":120.580907,"acc":15.0,"spd":0.4},{"t":1794376591000,"lat":24.209993,"lon":120.581138,"acc":15.0,"spd":0.6},{"t":1794376596000,"lat":24.210081,"lon":120.581022,"acc":15.0,"spd":0.0},{"t":1794376601000,"lat":24.209977,"lon":120.580967,"acc":15.0,"spd":0.5},{"t":1794376606000,"lat":24.209991,"lon":120.581017,"acc":15.0,"spd":0.4},{"t":1794376611000,"lat":24.20975,"lon":120.581112,"acc":15.0,"spd":0.1},{"t":1794376616000,"lat":24.209918,"lon":120.580812,"acc":15.0,"spd":2.9},{"t":1794376621000,"lat":24.209767,"lon":120.581099,"acc":15.0,"spd":8.3},{"t":1794376626000,"lat":24.209133,"lon":120.580935,"acc":15.0,"spd":9.1},{"t":1794376631000,"lat":24.208879,"lon":120.581151,"acc":15.0,"spd":9.2},{"t":1794376636000,"lat":24.208328,"lon":120.581282,"acc":15.0,"spd":7.8},{"t":1794376641000,"lat":24.207808,"lon":120.581225,"acc":15.0,"spd":7.9},{"t":1794376646000,"lat":24.207725,"lon":120.581198,"acc":15.0,"spd":8.5},{"t":1794376651000,"lat":24.207093,"lon":120.581205,"acc":15.0,"spd":8.8},{"t":1794376656000,"lat":24.206856,"lon":120.58133,"acc":15.0,"spd":11.3},{"t":1794376661000,"lat":24.205849,"lon":120.581084,"acc":15.0,"spd":15.8},{"t":1794376666000,"lat":24.205167,"lon":120.581036,"acc":15.0,"spd":16.0},{"t":1794376671000,"lat":24.204558,"lon":120.580958,"acc":15.0,"spd":16.4},{"t":1794376676000,"lat":24.20401,"lon":120.58091,"acc":15.0,"spd":15.8},{"t":1794376681000,"lat":24.203187,"lon":120.580841,"acc":15.0,"spd":16.1},{"t":1794376686000,"lat":24.202378,"lon":120.580857,"acc":15.0,"spd":16.0},{"t":1794376691000,"lat":24.201579,"lon":120.580574,"acc":15.0,"spd":15.6},{"t":1794376696000,"lat":24.201103,"lon":120.580627,"acc":15.0,"spd":15.6},{"t":1794376701000,"lat":24.200139,"lon":120.580655,"acc":15.0,"spd":15.9},{"t":1794376706000,"lat":24.199519,"lon":120.58052,"acc":15.0,"spd":16.3},{"t":1794376711000,"lat":24.198779,"lon":120.580303,"acc":15.0,"spd":16.1},{"t":1794376716000,"lat":24.198022,"lon":120.580479,"acc":15.0,"spd":16.0},{"t":1794376721000,"lat":24.197285,"lon":120.580221,"acc":15.0,"spd":17.4},{"t":1794376726000,"lat":24.196523,"lon":120.580099,"acc":15.0,"spd":17.3},{"t":1794376731000,"lat":24.195718,"lon":120.580062,"acc":15.0,"spd":15.8},{"t":1794376736000,"lat":24.195194,"lon":120.580273,"acc":15.0,"spd":14.1},{"t":1794376741000,"lat":24.194738,"lon":120.579888,"acc":15.0,"spd":13.9},{"t":1794376746000,"lat":24.194003,"lon":120.580171,"acc":15.0,"spd":13.2},{"t":1794376751000,"lat":24.193239,"lon":120.579675,"acc":15.0,"spd":13.8},{"t":1794376756000,"lat":24.192873,"lon":120.580004,"acc":15.0,"spd":9.0},{"t":1794376761000,"lat":24.192889,"lon":120.579614,"acc":15.0,"spd":1.6},{"t":1794376766000,"lat":24.192773,"lon":120.579918,"acc":15.0,"spd":1.3},{"t":1794376771000,"lat":24.193036,"lon":120.579992,"acc":15.0,"spd":1.0},{"t":1794376776000,"lat":24.192587,"lon":120.579731,"acc":15.0,"spd":0.5},{"t":1794376781000,"lat":24.192447,"lon":120.579725,"acc":15.0,"spd":0.2},{"t":1794376786000,"lat":24.192728,"lon":120.579974,"acc":15.0,"spd":0.0},{"t":1794376791000,"lat":24.192784,"lon":120.579841,"acc":15.0,"spd":0.0},{"t":1794376796000,"lat":24.192626,"lon":120.579856,"acc":15.0,"spd":3.8},{"t":1794376801000,"lat":24.192149,"lon":120.57968,"acc":15.0,"spd":10.4},{"t":1794376806000,"lat":24.191627,"lon":120.579848,"acc":15.0,"spd":10.0},{"t":1794376811000,"lat":24.191178,"lon":120.579975,"acc":15.0,"spd":10.6},{"t":1794376816000,"lat":24.190872,"lon":120.580067,"acc":15.0,"spd":10.1},{"t":1794376821000,"lat":24.190361,"lon":120.57994,"acc":15.0,"spd":9.7},{"t":1794376826000,"lat":24.189991,"lon":120.579956,"acc":15.0,"spd":10.6},{"t":1794376831000,"lat":24.189473,"lon":120.57985,"acc":15.0,"spd":10.0},{"t":1794376836000,"lat":24.188794,"lon":120.580083,"acc":15.0,"spd":9.8},{"t":1794376841000,"lat":24.188552,"lon":120.580798,"acc":15.0,"spd":15.0},{"t":1794376846000,"lat":24.188287,"lon":120.581482,"acc":15.0,"spd":14.8},{"t":1794376851000,"lat":24.18819,"lon":120.582247,"acc":15.0,"spd":14.4},{"t":1794376856000,"lat":24.187574,"lon":120.582866,"acc":15.0,"spd":15.6},{"t":1794376861000,"lat":24.18709,"lon":120.583287,"acc":15.0,"spd":15.7},{"t":1794376866000,"lat":24.186986,"lon":120.584046,"acc":15.0,"spd":14.5},{"t":1794376871000,"lat":24.186536,"lon":120.584716,"acc":15.0,"spd":15.1},{"t":1794376876000,"lat":24.186269,"lon":120.585083,"acc":15.0,"spd":10.8},{"t":1794376881000,"lat":24.186269,"lon":120.585517,"acc":15.0,"spd":5.4},{"t":1794376886000,"lat":24.186119,"lon":120.585728,"acc":15.0,"spd":5.0},{"t":1794376891000,"lat":24.186078,"lon":120.585961,"acc":15.0,"spd":5.4},{"t":1794376896000,"lat":24.185742,"lon":120.586281,"acc":15.0,"spd":9.0},{"t":1794376901000,"lat":24.185466,"lon":120.586921,"acc":15.0,"spd":12.7},{"t":1794376906000,"lat":24.18529,"lon":120.587434,"acc":15.0,"spd":11.3},{"t":1794376911000,"lat":24.185009,"lon":120.587974,"acc":15.0,"spd":12.0},{"t":1794376916000,"lat":24.185099,"lon":120.588412,"acc":15.0,"spd":10.6},{"t":1794376921000,"lat":24.184848,"lon":120.589029,"acc":15.0,"spd":9.0},{"t":1794376926000,"lat":24.184493,"lon":120.589327,"acc":15.0,"spd":8.2},{"t":1794376931000,"lat":24.184196,"lon":120.589698,"acc":15.0,"spd":8.8},{"t":1794376936000,"lat":24.184104,"lon":120.589956,"acc":15.0,"spd":8.5},{"t":1794376941000,"lat":24.184109,"lon":120.5903,"acc":15.0,"spd":7.0},{"t":1794376946000,"lat":24.18412,"lon":120.590766,"acc":15.0,"spd":7.9},{"t":1794376951000,"lat":24.183718,"lon":120.591035,"acc":15.0,"spd":8.5},{"t":1794376956000,"lat":24.1836,"lon":120.591235,"acc":15.0,"spd":5.4},{"t":1794376961000,"lat":24.183722,"lon":120.591194,"acc":15.0,"spd":1.9},{"t":1794376966000,"lat":24.183699,"lon":120.591334,"acc":15.0,"spd":2.6},{"t":1794376971000,"lat":24.183663,"lon":120.591631,"acc":15.0,"spd":2.6},{"t":1794376976000,"lat":24.183402,"lon":120.591627,"acc":15.0,"spd":3.0},{"t":1794376981000,"lat":24.183277,"lon":120.591766,"acc":15.0,"spd":2.9},{"t":1794376986000,"lat":24.183564,"lon":120.591965,"acc":15.0,"spd":2.1},{"t":1794376991000,"lat":24.18328,"lon":120.592143,"acc":15.0,"spd":2.5},{"t":1794376996000,"lat":24.183322,"lon":120.592151,"acc":15.0,"spd":2.9},{"t":1794377001000,"lat":24.183388,"lon":120.592055,"acc":15.0,"spd":3.2},{"t":1794377006000,"lat":24.183169,"lon":120.592376,"acc":15.0,"spd":2.8},{"t":1794377011000,"lat":24.183169,"lon":120.592649,"acc":15.0,"spd":2.7},{"t":1794377016000,"lat":24.183041,"lon":120.592722,"acc":15.0,"spd":2.4},{"t":1794377021000,"lat":24.183175,"lon":120.592856,"acc":15.0,"spd":2.3},{"t":1794377026000,"lat":24.183031,"lon":120.592861,"acc":15.0,"spd":2.8},{"t":1794377031000,"lat":24.183111,"lon":120.592956,"acc":15.0,"spd":2.9},{"t":1794377036000,"lat":24.182774,"lon":120.59332,"acc":15.0,"spd":3.0},{"t":1794377041000,"lat":24.183011,"lon":120.593344,"acc":15.0,"spd":2.5},{"t":1794377046000,"lat":24.182829,"lon":120.593388,"acc":15.0,"spd":2.6},{"t":1794377051000,"lat":24.183037,"lon":120.593542,"acc":15.0,"spd":2.9},{"t":1794377056000,"lat":24.182836,"lon":120.593611,"acc":15.0,"spd":1.8},{"t":1794377061000,"lat":24.182594,"lon":120.593651,"acc":15.0,"spd":2.6},{"t":1794377066000,"lat":24.182675,"lon":120.593811,"acc":15.0,"spd":2.3},{"t":1794377071000,"lat":24.182686,"lon":120.594084,"acc":15.0,"spd":2.1},{"t":1794377076000,"lat":24.182499,"lon":120.594163,"acc":15.0,"spd":6.0},{"t":1794377081000,"lat":24.182354,"lon":120.594655,"acc":15.0,"spd":10.5},{"t":1794377086000,"lat":24.182188,"lon":120.595252,"acc":15.0,"spd":10.2},{"t":1794377091000,"lat":24.18203,"lon":120.595661,"acc":15.0,"spd":10.8},{"t":1794377096000,"lat":24.182042,"lon":120.596079,"acc":15.0,"spd":11.2},{"t":1794377101000,"lat":24.182217,"lon":120.597018,"acc":15.0,"spd":12.2},{"t":1794377106000,"lat":24.182076,"lon":120.597274,"acc":15.0,"spd":12.0},{"t":1794377111000,"lat":24.182136,"lon":120.59785,"acc":15.0,"spd":12.2},{"t":1794377116000,"lat":24.182169,"lon":120.598676,"acc":15.0,"spd":12.0},{"t":1794377121000,"lat":24.182272,"lon":120.599169,"acc":15.0,"spd":12.3},{"t":1794377126000,"lat":24.182294,"lon":120.599674,"acc":15.0,"spd":11.8},{"t":1794377131000,"lat":24.182325,"lon":120.600396,"acc":15.0,"spd":12.0},{"t":1794377136000,"lat":24.182308,"lon":120.600981,"acc":15.0,"spd":11.7},{"t":1794377141000,"lat":24.182582,"lon":120.601528,"acc":15.0,"spd":11.4},{"t":1794377146000,"lat":24.182508,"lon":120.6018,"acc":15.0,"spd":11.0},{"t":1794377151000,"lat":24.182739,"lon":120.602282,"acc":15.0,"spd":4.8},{"t":1794377156000,"lat":24.182732,"lon":120.602517,"acc":15.0,"spd":5.5},{"t":1794377161000,"lat":24.182795,"lon":120.602751,"acc":15.0,"spd":5.1},{"t":1794377166000,"lat":24.182926,"lon":120.602906,"acc":15.0,"spd":4.7},{"t":1794377171000,"lat":24.182964,"lon":120.603174,"acc":15.0,"spd":4.1},{"t":1794377176000,"lat":24.18296,"lon":120.603228,"acc":15.0,"spd":3.1},{"t":1794377181000,"lat":24.182974,"lon":120.603244,"acc":15.0,"spd":0.0},{"t":1794377186000,"lat":24.182991,"lon":120.603374,"acc":15.0,"spd":0.0},{"t":1794377191000,"lat":24.182893,"lon":120.603138,"acc":15.0,"spd":0.3},{"t":1794377196000,"lat":24.182929,"lon":120.603331,"acc":15.0,"spd":0.0},{"t":1794377201000,"lat":24.183168,"lon":120.603309,"acc":15.0,"spd":1.1},{"t":1794377206000,"lat":24.182972,"lon":120.603552,"acc":15.0,"spd":0.9},{"t":1794377211000,"lat":24.182703,"lon":120.603707,"acc":15.0,"spd":1.3},{"t":1794377216000,"lat":24.183023,"lon":120.60372,"acc":15.0,"spd":3.2},{"t":1794377221000,"lat":24.183101,"lon":120.604064,"acc":15.0,"spd":7.1},{"t":1794377226000,"lat":24.183147,"lon":120.604591,"acc":15.0,"spd":7.0},{"t":1794377231000,"lat":24.183243,"lon":120.604908,"acc":15.0,"spd":7.5},{"t":1794377236000,"lat":24.183129,"lon":120.605236,"acc":15.0,"spd":7.8},{"t":1794377241000,"lat":24.183364,"lon":120.605386,"acc":15.0,"spd":7.3},{"t":1794377246000,"lat":24.183385,"lon":120.60577,"acc":15.0,"spd":7.4},{"t":1794377251000,"lat":24.183444,"lon":120.605885,"acc":15.0,"spd":7.5},{"t":1794377256000,"lat":24.183789,"lon":120.60644,"acc":15.0,"spd":9.1},{"t":1794377261000,"lat":24.183991,"lon":120.607316,"acc":15.0,"spd":13.5},{"t":1794377266000,"lat":24.183859,"lon":120.607777,"acc":15.0,"spd":12.6},{"t":1794377271000,"lat":24.183969,"lon":120.608901,"acc":15.0,"spd":13.9},{"t":1794377276000,"lat":24.184318,"lon":120.609052,"acc":15.0,"spd":9.7},{"t":1794377281000,"lat":24.184305,"lon":120.609197,"acc":15.0,"spd":5.8},{"t":1794377286000,"lat":24.184345,"lon":120.609529,"acc":15.0,"spd":7.2},{"t":1794377291000,"lat":24.184163,"lon":120.610054,"acc":15.0,"spd":5.6},{"t":1794377296000,"lat":24.184358,"lon":120.609968,"acc":15.0,"spd":3.8},{"t":1794377301000,"lat":24.184366,"lon":120.610066,"acc":15.0,"spd":1.3},{"t":1794377306000,"lat":24.184589,"lon":120.610308,"acc":15.0,"spd":1.9},{"t":1794377311000,"lat":24.184406,"lon":120.610476,"acc":15.0,"spd":2.5},{"t":1794377316000,"lat":24.184228,"lon":120.610592,"acc":15.0,"spd":6.1},{"t":1794377321000,"lat":24.184208,"lon":120.611165,"acc":15.0,"spd":12.0},{"t":1794377326000,"lat":24.184172,"lon":120.611828,"acc":15.0,"spd":11.1},{"t":1794377331000,"lat":24.1841,"lon":120.612082,"acc":15.0,"spd":11.0},{"t":1794377336000,"lat":24.184008,"lon":120.612779,"acc":15.0,"spd":11.4},{"t":1794377341000,"lat":24.183957,"lon":120.613194,"acc":15.0,"spd":11.2},{"t":1794377346000,"lat":24.183545,"lon":120.613735,"acc":15.0,"spd":11.5},{"t":1794377351000,"lat":24.183306,"lon":120.614328,"acc":15.0,"spd":10.6},{"t":1794377356000,"lat":24.183257,"lon":120.615007,"acc":15.0,"spd":11.7},{"t":1794377361000,"lat":24.183113,"lon":120.615212,"acc":15.0,"spd":11.5},{"t":1794377366000,"lat":24.1828,"lon":120.615766,"acc":15.0,"spd":10.7},{"t":1794377371000,"lat":24.182607,"lon":120.616436,"acc":15.0,"spd":11.2},{"t":1794377376000,"lat":24.18248,"lon":120.616037,"acc":15.0,"spd":0.0},{"t":1794377381000,"lat":24.182546,"lon":120.61619,"acc":15.0,"spd":0.0},{"t":1794377386000,"lat":24.182525,"lon":120.616244,"acc":15.0,"spd":0.0},{"t":1794377391000,"lat":24.182439,"lon":120.616248,"acc":15.0,"spd":0.0},{"t":1794377396000,"lat":24.182408,"lon":120.616353,"acc":15.0,"spd":0.0},{"t":1794377401000,"lat":24.182474,"lon":120.616392,"acc":15.0,"spd":0.1}],"waitingFixes":[{"t":1794376156000,"lat":24.221671,"lon":120.578961,"acc":25.0,"spd":0.1},{"t":1794376161000,"lat":24.221933,"lon":120.579542,"acc":25.0,"spd":0.0},{"t":1794376166000,"lat":24.22194,"lon":120.579291,"acc":25.0,"spd":0.7},{"t":1794376171000,"lat":24.221731,"lon":120.579494,"acc":25.0,"spd":0.3},{"t":1794376176000,"lat":24.221574,"lon":120.579111,"acc":25.0,"spd":0.0},{"t":1794376181000,"lat":24.221893,"lon":120.578865,"acc":25.0,"spd":0.4},{"t":1794376186000,"lat":24.221995,"lon":120.579203,"acc":25.0,"spd":0.5},{"t":1794376191000,"lat":24.221794,"lon":120.579028,"acc":25.0,"spd":0.0},{"t":1794376196000,"lat":24.222027,"lon":120.579316,"acc":25.0,"spd":0.2},{"t":1794376201000,"lat":24.222029,"lon":120.579252,"acc":25.0,"spd":0.2},{"t":1794376206000,"lat":24.222027,"lon":120.579215,"acc":25.0,"spd":0.0},{"t":1794376211000,"lat":24.221952,"lon":120.579231,"acc":25.0,"spd":0.4},{"t":1794376216000,"lat":24.22183,"lon":120.579146,"acc":25.0,"spd":0.5},{"t":1794376221000,"lat":24.222061,"lon":120.579011,"acc":25.0,"spd":0.1},{"t":1794376226000,"lat":24.221999,"lon":120.579313,"acc":25.0,"spd":0.1},{"t":1794376231000,"lat":24.221972,"lon":120.578824,"acc":25.0,"spd":0.4},{"t":1794376236000,"lat":24.221659,"lon":120.578837,"acc":25.0,"spd":0.2},{"t":1794376241000,"lat":24.222005,"lon":120.579178,"acc":25.0,"spd":0.3},{"t":1794376246000,"lat":24.221894,"lon":120.579141,"acc":25.0,"spd":0.4},{"t":1794376251000,"lat":24.221807,"lon":120.57921,"acc":25.0,"spd":0.1},{"t":1794376256000,"lat":24.221953,"lon":120.578864,"acc":25.0,"spd":0.2},{"t":1794376261000,"lat":24.221692,"lon":120.579095,"acc":25.0,"spd":0.1},{"t":1794376266000,"lat":24.221823,"lon":120.579227,"acc":25.0,"spd":0.1},{"t":1794376271000,"lat":24.221983,"lon":120.579009,"acc":25.0,"spd":0.7},{"t":1794376276000,"lat":24.221552,"lon":120.579048,"acc":25.0,"spd":0.3},{"t":1794376281000,"lat":24.222037,"lon":120.579016,"acc":25.0,"spd":0.3},{"t":1794376286000,"lat":24.221824,"lon":120.579028,"acc":25.0,"spd":0.2},{"t":1794376291000,"lat":24.221743,"lon":120.579043,"acc":25.0,"spd":0.1},{"t":1794376296000,"lat":24.221967,"lon":120.579207,"acc":25.0,"spd":0.1},{"t":1794376301000,"lat":24.221883,"lon":120.578938,"acc":25.0,"spd":0.1},{"t":1794376306000,"lat":24.221806,"lon":120.57901,"acc":25.0,"spd":0.2},{"t":1794376311000,"lat":24.221969,"lon":120.579238,"acc":25.0,"spd":0.0},{"t":1794376316000,"lat":24.221694,"lon":120.57836,"acc":25.0,"spd":0.0},{"t":1794376321000,"lat":24.221887,"lon":120.579078,"acc":25.0,"spd":0.1},{"t":1794376326000,"lat":24.221822,"lon":120.580125,"acc":25.0,"spd":0.3},{"t":1794376331000,"lat":24.222116,"lon":120.579478,"acc":25.0,"spd":0.1},{"t":1794376336000,"lat":24.221629,"lon":120.579293,"acc":25.0,"spd":0.0},{"t":1794376341000,"lat":24.221852,"lon":120.579292,"acc":25.0,"spd":0.2},{"t":1794376346000,"lat":24.221799,"lon":120.578917,"acc":25.0,"spd":0.2},{"t":1794376351000,"lat":24.221561,"lon":120.578873,"acc":25.0,"spd":0.1},{"t":1794376356000,"lat":24.221806,"lon":120.57899,"acc":25.0,"spd":0.4},{"t":1794376361000,"lat":24.221913,"lon":120.579019,"acc":25.0,"spd":0.6},{"t":1794376366000,"lat":24.221439,"lon":120.578967,"acc":25.0,"spd":0.3},{"t":1794376371000,"lat":24.221822,"lon":120.579276,"acc":25.0,"spd":0.4},{"t":1794376376000,"lat":24.221981,"lon":120.578957,"acc":25.0,"spd":0.2},{"t":1794376381000,"lat":24.221988,"lon":120.579049,"acc":25.0,"spd":0.7},{"t":1794376386000,"lat":24.221912,"lon":120.579189,"acc":25.0,"spd":0.2},{"t":1794376391000,"lat":24.221969,"lon":120.578978,"acc":25.0,"spd":0.1},{"t":1794376396000,"lat":24.222025,"lon":120.579972,"acc":25.0,"spd":0.0},{"t":1794376401000,"lat":24.221774,"lon":120.579691,"acc":25.0,"spd":0.0},{"t":1794376406000,"lat":24.221815,"lon":120.579441,"acc":25.0,"spd":0.5},{"t":1794376411000,"lat":24.221703,"lon":120.579214,"acc":25.0,"spd":0.1},{"t":1794376416000,"lat":24.22174,"lon":120.579344,"acc":25.0,"spd":0.7},{"t":1794376421000,"lat":24.22205,"lon":120.579264,"acc":25.0,"spd":0.2},{"t":1794376426000,"lat":24.22181,"lon":120.578959,"acc":25.0,"spd":0.2},{"t":1794376431000,"lat":24.221879,"lon":120.579136,"acc":25.0,"spd":0.2},{"t":1794376436000,"lat":24.221867,"lon":120.579204,"acc":25.0,"spd":0.3},{"t":1794376441000,"lat":24.222069,"lon":120.579007,"acc":25.0,"spd":0.4},{"t":1794376446000,"lat":24.221836,"lon":120.579087,"acc":25.0,"spd":0.2},{"t":1794376451000,"lat":24.221816,"lon":120.579177,"acc":25.0,"spd":0.0},{"t":1794376456000,"lat":24.22178,"lon":120.57904,"acc":25.0,"spd":0.0},{"t":1794376461000,"lat":24.221812,"lon":120.579051,"acc":25.0,"spd":0.0},{"t":1794376466000,"lat":24.221692,"lon":120.57911,"acc":25.0,"spd":0.0},{"t":1794376471000,"lat":24.221955,"lon":120.579093,"acc":25.0,"spd":0.5},{"t":1794376476000,"lat":24.221792,"lon":120.579178,"acc":25.0,"spd":0.0},{"t":1794376481000,"lat":24.221945,"lon":120.579051,"acc":25.0,"spd":0.3},{"t":1794376486000,"lat":24.221857,"lon":120.578944,"acc":25.0,"spd":0.1},{"t":1794376491000,"lat":24.221716,"lon":120.579104,"acc":25.0,"spd":0.1},{"t":1794376496000,"lat":24.221846,"lon":120.579117,"acc":25.0,"spd":0.4},{"t":1794376501000,"lat":24.221801,"lon":120.579389,"acc":25.0,"spd":0.4},{"t":1794376506000,"lat":24.221934,"lon":120.579276,"acc":25.0,"spd":0.1},{"t":1794376511000,"lat":24.221845,"lon":120.5789,"acc":25.0,"spd":0.2},{"t":1794376516000,"lat":24.221849,"lon":120.578759,"acc":25.0,"spd":0.2},{"t":1794376521000,"lat":24.22199,"lon":120.579065,"acc":25.0,"spd":0.3},{"t":1794376526000,"lat":24.221981,"lon":120.578992,"acc":25.0,"spd":0.2},{"t":1794376531000,"lat":24.22193,"lon":120.579479,"acc":25.0,"spd":0.5},{"t":1794376536000,"lat":24.221909,"lon":120.579113,"acc":25.0,"spd":0.4},{"t":1794376541000,"lat":24.222057,"lon":120.578977,"acc":25.0,"spd":0.1},{"t":1794376546000,"lat":24.222015,"lon":120.57893,"acc":25.0,"spd":0.2},{"t":1794376551000,"lat":24.22221,"lon":120.578835,"acc":25.0,"spd":0.1},{"t":1794376556000,"lat":24.221902,"lon":120.579094,"acc":25.0,"spd":0.1},{"t":1794376561000,"lat":24.221906,"lon":120.57909,"acc":25.0,"spd":0.4},{"t":1794376566000,"lat":24.221985,"lon":120.579063,"acc":25.0,"spd":0.2},{"t":1794376571000,"lat":24.22208,"lon":120.578855,"acc":25.0,"spd":0.1},{"t":1794376576000,"lat":24.221897,"lon":120.579083,"acc":25.0,"spd":0.1},{"t":1794376581000,"lat":24.221846,"lon":120.579545,"acc":25.0,"spd":1.0},{"t":1794376586000,"lat":24.222031,"lon":120.579591,"acc":25.0,"spd":0.2},{"t":1794376591000,"lat":24.221854,"lon":120.57937,"acc":25.0,"spd":0.3},{"t":1794376596000,"lat":24.221871,"lon":120.578017,"acc":25.0,"spd":0.2},{"t":1794376601000,"lat":24.222032,"lon":120.578556,"acc":25.0,"spd":0.2},{"t":1794376606000,"lat":24.221876,"lon":120.57886,"acc":25.0,"spd":0.5},{"t":1794376611000,"lat":24.221912,"lon":120.579033,"acc":25.0,"spd":0.2},{"t":1794376616000,"lat":24.222024,"lon":120.579161,"acc":25.0,"spd":0.2},{"t":1794376621000,"lat":24.221816,"lon":120.578902,"acc":25.0,"spd":0.1},{"t":1794376626000,"lat":24.221975,"lon":120.578858,"acc":25.0,"spd":0.2},{"t":1794376631000,"lat":24.222052,"lon":120.578825,"acc":25.0,"spd":0.1},{"t":1794376636000,"lat":24.221909,"lon":120.579086,"acc":25.0,"spd":0.5},{"t":1794376641000,"lat":24.222026,"lon":120.579052,"acc":25.0,"spd":0.5},{"t":1794376646000,"lat":24.221688,"lon":120.579114,"acc":25.0,"spd":0.2},{"t":1794376651000,"lat":24.221952,"lon":120.579063,"acc":25.0,"spd":0.2},{"t":1794376656000,"lat":24.221763,"lon":120.578902,"acc":25.0,"spd":0.4},{"t":1794376661000,"lat":24.221939,"lon":120.579146,"acc":25.0,"spd":0.3},{"t":1794376666000,"lat":24.221847,"lon":120.579256,"acc":25.0,"spd":0.0},{"t":1794376671000,"lat":24.222013,"lon":120.578965,"acc":25.0,"spd":0.1},{"t":1794376676000,"lat":24.221848,"lon":120.579035,"acc":25.0,"spd":0.1},{"t":1794376681000,"lat":24.221781,"lon":120.579003,"acc":25.0,"spd":0.1},{"t":1794376686000,"lat":24.221642,"lon":120.578874,"acc":25.0,"spd":0.4},{"t":1794376691000,"lat":24.221743,"lon":120.579242,"acc":25.0,"spd":0.1},{"t":1794376696000,"lat":24.221707,"lon":120.579042,"acc":25.0,"spd":0.1},{"t":1794376701000,"lat":24.221655,"lon":120.579036,"acc":25.0,"spd":0.3},{"t":1794376706000,"lat":24.221929,"lon":120.579003,"acc":25.0,"spd":0.2},{"t":1794376711000,"lat":24.222058,"lon":120.579047,"acc":25.0,"spd":0.1},{"t":1794376716000,"lat":24.22163,"lon":120.579204,"acc":25.0,"spd":0.1},{"t":1794376721000,"lat":24.221907,"lon":120.579298,"acc":25.0,"spd":0.1},{"t":1794376726000,"lat":24.221721,"lon":120.579494,"acc":25.0,"spd":0.4},{"t":1794376731000,"lat":24.22156,"lon":120.579129,"acc":25.0,"spd":0.2},{"t":1794376736000,"lat":24.22179,"lon":120.579128,"acc":25.0,"spd":0.5},{"t":1794376741000,"lat":24.22203,"lon":120.579174,"acc":25.0,"spd":0.3},{"t":1794376746000,"lat":24.221845,"lon":120.579261,"acc":25.0,"spd":0.1},{"t":1794376751000,"lat":24.221643,"lon":120.579108,"acc":25.0,"spd":0.1},{"t":1794376756000,"lat":24.221911,"lon":120.57919,"acc":25.0,"spd":0.0},{"t":1794376761000,"lat":24.221566,"lon":120.579239,"acc":25.0,"spd":0.3},{"t":1794376766000,"lat":24.222152,"lon":120.579084,"acc":25.0,"spd":0.1},{"t":1794376771000,"lat":24.221775,"lon":120.578922,"acc":25.0,"spd":0.5},{"t":1794376776000,"lat":24.221871,"lon":120.578923,"acc":25.0,"spd":0.1},{"t":1794376781000,"lat":24.221884,"lon":120.579441,"acc":25.0,"spd":0.1},{"t":1794376786000,"lat":24.221717,"lon":120.57924,"acc":25.0,"spd":0.0},{"t":1794376791000,"lat":24.221751,"lon":120.578814,"acc":25.0,"spd":0.1},{"t":1794376796000,"lat":24.2219,"lon":120.579171,"acc":25.0,"spd":0.1},{"t":1794376801000,"lat":24.221777,"lon":120.578908,"acc":25.0,"spd":0.2},{"t":1794376806000,"lat":24.221798,"lon":120.578929,"acc":25.0,"spd":0.1},{"t":1794376811000,"lat":24.222061,"lon":120.578898,"acc":25.0,"spd":0.1},{"t":1794376816000,"lat":24.221925,"lon":120.579233,"acc":25.0,"spd":0.4},{"t":1794376821000,"lat":24.221807,"lon":120.579274,"acc":25.0,"spd":0.5},{"t":1794376826000,"lat":24.221914,"lon":120.579296,"acc":25.0,"spd":0.3},{"t":1794376831000,"lat":24.221726,"lon":120.579086,"acc":25.0,"spd":0.4},{"t":1794376836000,"lat":24.221875,"lon":120.579069,"acc":25.0,"spd":0.3},{"t":1794376841000,"lat":24.222104,"lon":120.579135,"acc":25.0,"spd":0.7},{"t":1794376846000,"lat":24.221785,"lon":120.579149,"acc":25.0,"spd":0.1},{"t":1794376851000,"lat":24.221974,"lon":120.579369,"acc":25.0,"spd":0.5},{"t":1794376856000,"lat":24.221928,"lon":120.579121,"acc":25.0,"spd":0.3},{"t":1794376861000,"lat":24.221789,"lon":120.579246,"acc":25.0,"spd":0.3},{"t":1794376866000,"lat":24.222016,"lon":120.578921,"acc":25.0,"spd":0.4},{"t":1794376871000,"lat":24.221884,"lon":120.579391,"acc":25.0,"spd":0.1},{"t":1794376876000,"lat":24.221611,"lon":120.57886,"acc":25.0,"spd":0.0},{"t":1794376881000,"lat":24.222006,"lon":120.579002,"acc":25.0,"spd":0.4},{"t":1794376886000,"lat":24.222026,"lon":120.578874,"acc":25.0,"spd":0.1},{"t":1794376891000,"lat":24.222072,"lon":120.579026,"acc":25.0,"spd":0.0},{"t":1794376896000,"lat":24.221963,"lon":120.578786,"acc":25.0,"spd":0.1},{"t":1794376901000,"lat":24.221662,"lon":120.579457,"acc":25.0,"spd":0.1},{"t":1794376906000,"lat":24.22177,"lon":120.579166,"acc":25.0,"spd":0.3},{"t":1794376911000,"lat":24.22181,"lon":120.579208,"acc":25.0,"spd":0.2},{"t":1794376916000,"lat":24.221888,"lon":120.579135,"acc":25.0,"spd":0.3},{"t":1794376921000,"lat":24.221614,"lon":120.578848,"acc":25.0,"spd":0.4},{"t":1794376926000,"lat":24.221938,"lon":120.578996,"acc":25.0,"spd":0.4},{"t":1794376931000,"lat":24.221584,"lon":120.578896,"acc":25.0,"spd":0.2},{"t":1794376936000,"lat":24.221784,"lon":120.579179,"acc":25.0,"spd":0.5},{"t":1794376941000,"lat":24.221985,"lon":120.578968,"acc":25.0,"spd":0.1},{"t":1794376946000,"lat":24.221948,"lon":120.579099,"acc":25.0,"spd":0.1},{"t":1794376951000,"lat":24.221924,"lon":120.579298,"acc":25.0,"spd":0.2},{"t":1794376956000,"lat":24.221951,"lon":120.57838,"acc":25.0,"spd":0.4},{"t":1794376961000,"lat":24.221958,"lon":120.578601,"acc":25.0,"spd":0.2},{"t":1794376966000,"lat":24.221745,"lon":120.57876,"acc":25.0,"spd":0.4},{"t":1794376971000,"lat":24.22172,"lon":120.579032,"acc":25.0,"spd":0.1},{"t":1794376976000,"lat":24.221936,"lon":120.578819,"acc":25.0,"spd":0.1},{"t":1794376981000,"lat":24.221768,"lon":120.578904,"acc":25.0,"spd":0.1},{"t":1794376986000,"lat":24.221859,"lon":120.578785,"acc":25.0,"spd":0.1},{"t":1794376991000,"lat":24.22214,"lon":120.57895,"acc":25.0,"spd":0.0},{"t":1794376996000,"lat":24.221878,"lon":120.579168,"acc":25.0,"spd":0.2},{"t":1794377001000,"lat":24.22189,"lon":120.579141,"acc":25.0,"spd":0.2},{"t":1794377006000,"lat":24.222096,"lon":120.579283,"acc":25.0,"spd":0.2},{"t":1794377011000,"lat":24.221977,"lon":120.579108,"acc":25.0,"spd":0.6},{"t":1794377016000,"lat":24.222072,"lon":120.579117,"acc":25.0,"spd":0.5},{"t":1794377021000,"lat":24.221762,"lon":120.579296,"acc":25.0,"spd":0.2},{"t":1794377026000,"lat":24.222006,"lon":120.579137,"acc":25.0,"spd":0.3},{"t":1794377031000,"lat":24.221792,"lon":120.579018,"acc":25.0,"spd":0.2},{"t":1794377036000,"lat":24.221923,"lon":120.578888,"acc":25.0,"spd":0.3},{"t":1794377041000,"lat":24.221674,"lon":120.579236,"acc":25.0,"spd":0.3},{"t":1794377046000,"lat":24.221883,"lon":120.579276,"acc":25.0,"spd":0.2},{"t":1794377051000,"lat":24.221861,"lon":120.579141,"acc":25.0,"spd":0.2},{"t":1794377056000,"lat":24.221997,"lon":120.579053,"acc":25.0,"spd":0.2},{"t":1794377061000,"lat":24.221899,"lon":120.579163,"acc":25.0,"spd":0.0},{"t":1794377066000,"lat":24.221897,"lon":120.57923,"acc":25.0,"spd":0.1},{"t":1794377071000,"lat":24.221871,"lon":120.579202,"acc":25.0,"spd":0.1},{"t":1794377076000,"lat":24.221438,"lon":120.579128,"acc":25.0,"spd":0.4},{"t":1794377081000,"lat":24.221633,"lon":120.578915,"acc":25.0,"spd":0.2},{"t":1794377086000,"lat":24.221805,"lon":120.579234,"acc":25.0,"spd":0.4},{"t":1794377091000,"lat":24.221773,"lon":120.578894,"acc":25.0,"spd":0.1},{"t":1794377096000,"lat":24.222037,"lon":120.578968,"acc":25.0,"spd":0.7},{"t":1794377101000,"lat":24.22157,"lon":120.578971,"acc":25.0,"spd":0.1},{"t":1794377106000,"lat":24.222065,"lon":120.578985,"acc":25.0,"spd":0.2},{"t":1794377111000,"lat":24.221921,"lon":120.579019,"acc":25.0,"spd":0.5},{"t":1794377116000,"lat":24.221892,"lon":120.579075,"acc":25.0,"spd":0.1},{"t":1794377121000,"lat":24.221933,"lon":120.579029,"acc":25.0,"spd":0.4},{"t":1794377126000,"lat":24.222033,"lon":120.579045,"acc":25.0,"spd":0.1},{"t":1794377131000,"lat":24.221675,"lon":120.578938,"acc":25.0,"spd":0.2},{"t":1794377136000,"lat":24.221777,"lon":120.579008,"acc":25.0,"spd":0.1},{"t":1794377141000,"lat":24.221736,"lon":120.579292,"acc":25.0,"spd":0.2},{"t":1794377146000,"lat":24.221944,"lon":120.579208,"acc":25.0,"spd":0.3},{"t":1794377151000,"lat":24.222003,"lon":120.579169,"acc":25.0,"spd":0.6},{"t":1794377156000,"lat":24.221893,"lon":120.579038,"acc":25.0,"spd":0.2},{"t":1794377161000,"lat":24.221957,"lon":120.579022,"acc":25.0,"spd":0.3},{"t":1794377166000,"lat":24.221711,"lon":120.579302,"acc":25.0,"spd":0.0},{"t":1794377171000,"lat":24.221694,"lon":120.578918,"acc":25.0,"spd":0.2},{"t":1794377176000,"lat":24.221774,"lon":120.579182,"acc":25.0,"spd":0.1},{"t":1794377181000,"lat":24.221746,"lon":120.578895,"acc":25.0,"spd":0.4},{"t":1794377186000,"lat":24.221634,"lon":120.578865,"acc":25.0,"spd":0.4},{"t":1794377191000,"lat":24.222,"lon":120.5789,"acc":25.0,"spd":0.1},{"t":1794377196000,"lat":24.22186,"lon":120.579024,"acc":25.0,"spd":0.2},{"t":1794377201000,"lat":24.222159,"lon":120.579287,"acc":25.0,"spd":0.4},{"t":1794377206000,"lat":24.221828,"lon":120.579301,"acc":25.0,"spd":0.1},{"t":1794377211000,"lat":24.221822,"lon":120.578887,"acc":25.0,"spd":0.2},{"t":1794377216000,"lat":24.22184,"lon":120.579102,"acc":25.0,"spd":0.2},{"t":1794377221000,"lat":24.221643,"lon":120.579173,"acc":25.0,"spd":0.2},{"t":1794377226000,"lat":24.221771,"lon":120.578895,"acc":25.0,"spd":0.2},{"t":1794377231000,"lat":24.221884,"lon":120.579292,"acc":25.0,"spd":0.4},{"t":1794377236000,"lat":24.221626,"lon":120.579081,"acc":25.0,"spd":0.3},{"t":1794377241000,"lat":24.221705,"lon":120.579006,"acc":25.0,"spd":0.2},{"t":1794377246000,"lat":24.221822,"lon":120.579097,"acc":25.0,"spd":0.3},{"t":1794377251000,"lat":24.221955,"lon":120.579225,"acc":25.0,"spd":0.3},{"t":1794377256000,"lat":24.221811,"lon":120.579049,"acc":25.0,"spd":0.0},{"t":1794377261000,"lat":24.22181,"lon":120.579038,"acc":25.0,"spd":0.7},{"t":1794377266000,"lat":24.221768,"lon":120.579019,"acc":25.0,"spd":0.1},{"t":1794377271000,"lat":24.221835,"lon":120.57896,"acc":25.0,"spd":0.2},{"t":1794377276000,"lat":24.221681,"lon":120.578864,"acc":25.0,"spd":0.2},{"t":1794377281000,"lat":24.221699,"lon":120.57929,"acc":25.0,"spd":0.0},{"t":1794377286000,"lat":24.221651,"lon":120.579066,"acc":25.0,"spd":0.2},{"t":1794377291000,"lat":24.221918,"lon":120.579225,"acc":25.0,"spd":0.2},{"t":1794377296000,"lat":24.22174,"lon":120.578889,"acc":25.0,"spd":0.6},{"t":1794377301000,"lat":24.221885,"lon":120.579107,"acc":25.0,"spd":0.2},{"t":1794377306000,"lat":24.221759,"lon":120.579148,"acc":25.0,"spd":0.1},{"t":1794377311000,"lat":24.221787,"lon":120.579913,"acc":25.0,"spd":0.1},{"t":1794377316000,"lat":24.221895,"lon":120.579658,"acc":25.0,"spd":0.2},{"t":1794377321000,"lat":24.222087,"lon":120.579451,"acc":25.0,"spd":0.1},{"t":1794377326000,"lat":24.221759,"lon":120.579195,"acc":25.0,"spd":0.8},{"t":1794377331000,"lat":24.221871,"lon":120.579231,"acc":25.0,"spd":0.1},{"t":1794377336000,"lat":24.221645,"lon":120.579271,"acc":25.0,"spd":0.0},{"t":1794377341000,"lat":24.221809,"lon":120.580019,"acc":25.0,"spd":0.3},{"t":1794377346000,"lat":24.221916,"lon":120.579442,"acc":25.0,"spd":0.4},{"t":1794377351000,"lat":24.22189,"lon":120.579053,"acc":25.0,"spd":0.1},{"t":1794377356000,"lat":24.221582,"lon":120.579365,"acc":25.0,"spd":0.0},{"t":1794377361000,"lat":24.221944,"lon":120.579303,"acc":25.0,"spd":0.1},{"t":1794377366000,"lat":24.221905,"lon":120.579053,"acc":25.0,"spd":0.1},{"t":1794377371000,"lat":24.22225,"lon":120.578915,"acc":25.0,"spd":0.1},{"t":1794377376000,"lat":24.221913,"lon":120.578999,"acc":25.0,"spd":0.2},{"t":1794377381000,"lat":24.221807,"lon":120.579163,"acc":25.0,"spd":0.2},{"t":1794377386000,"lat":24.221804,"lon":120.578941,"acc":25.0,"spd":0.0},{"t":1794377391000,"lat":24.221902,"lon":120.578897,"acc":25.0,"spd":0.1},{"t":1794377396000,"lat":24.2219,"lon":120.57847,"acc":25.0,"spd":0.1},{"t":1794377401000,"lat":24.22206,"lon":120.578854,"acc":25.0,"spd":0.6}],"walkerFixes":[{"t":1794376156000,"lat":24.221811,"lon":120.579087,"acc":15.0,"spd":1.8},{"t":1794376161000,"lat":24.221788,"lon":120.579113,"acc":15.0,"spd":1.4},{"t":1794376166000,"lat":24.221703,"lon":120.579004,"acc":15.0,"spd":1.2},{"t":1794376171000,"lat":24.22166,"lon":120.579222,"acc":15.0,"spd":1.2},{"t":1794376176000,"lat":24.221696,"lon":120.579176,"acc":15.0,"spd":1.6},{"t":1794376181000,"lat":24.221401,"lon":120.579256,"acc":15.0,"spd":1.7},{"t":1794376186000,"lat":24.221619,"lon":120.579041,"acc":15.0,"spd":1.2},{"t":1794376191000,"lat":24.221506,"lon":120.579208,"acc":15.0,"spd":1.8},{"t":1794376196000,"lat":24.221258,"lon":120.579058,"acc":15.0,"spd":1.1},{"t":1794376201000,"lat":24.221205,"lon":120.579501,"acc":15.0,"spd":1.4},{"t":1794376206000,"lat":24.221152,"lon":120.579263,"acc":15.0,"spd":1.1},{"t":1794376211000,"lat":24.22144,"lon":120.579036,"acc":15.0,"spd":1.5},{"t":1794376216000,"lat":24.221207,"lon":120.579312,"acc":15.0,"spd":1.5},{"t":1794376221000,"lat":24.221026,"lon":120.57929,"acc":15.0,"spd":1.6},{"t":1794376226000,"lat":24.22099,"lon":120.579389,"acc":15.0,"spd":1.4},{"t":1794376231000,"lat":24.220901,"lon":120.579298,"acc":15.0,"spd":1.2},{"t":1794376236000,"lat":24.220933,"lon":120.579396,"acc":15.0,"spd":1.5},{"t":1794376241000,"lat":24.220905,"lon":120.579466,"acc":15.0,"spd":1.2},{"t":1794376246000,"lat":24.220775,"lon":120.579449,"acc":15.0,"spd":1.6},{"t":1794376251000,"lat":24.220613,"lon":120.579388,"acc":15.0,"spd":1.3},{"t":1794376256000,"lat":24.220684,"lon":120.579399,"acc":15.0,"spd":1.2},{"t":1794376261000,"lat":24.220618,"lon":120.579435,"acc":15.0,"spd":1.5},{"t":1794376266000,"lat":24.220773,"lon":120.579464,"acc":15.0,"spd":1.5},{"t":1794376271000,"lat":24.220486,"lon":120.579703,"acc":15.0,"spd":1.5},{"t":1794376276000,"lat":24.220459,"lon":120.579405,"acc":15.0,"spd":1.3},{"t":1794376281000,"lat":24.220418,"lon":120.579499,"acc":15.0,"spd":1.1},{"t":1794376286000,"lat":24.220173,"lon":120.579575,"acc":15.0,"spd":1.8},{"t":1794376291000,"lat":24.220396,"lon":120.57962,"acc":15.0,"spd":1.5},{"t":1794376296000,"lat":24.220075,"lon":120.579432,"acc":15.0,"spd":1.4},{"t":1794376301000,"lat":24.220118,"lon":120.579627,"acc":15.0,"spd":1.6},{"t":1794376306000,"lat":24.220018,"lon":120.579603,"acc":15.0,"spd":1.5},{"t":1794376311000,"lat":24.22,"lon":120.579538,"acc":15.0,"spd":1.8},{"t":1794376316000,"lat":24.220008,"lon":120.579682,"acc":15.0,"spd":1.4},{"t":1794376321000,"lat":24.219598,"lon":120.579671,"acc":15.0,"spd":1.4},{"t":1794376326000,"lat":24.219669,"lon":120.579963,"acc":15.0,"spd":1.4},{"t":1794376331000,"lat":24.219644,"lon":120.579536,"acc":15.0,"spd":1.5},{"t":1794376336000,"lat":24.219536,"lon":120.579964,"acc":15.0,"spd":1.7},{"t":1794376341000,"lat":24.219703,"lon":120.579657,"acc":15.0,"spd":1.5},{"t":1794376346000,"lat":24.219688,"lon":120.579633,"acc":15.0,"spd":1.7},{"t":1794376351000,"lat":24.219487,"lon":120.579981,"acc":15.0,"spd":1.1},{"t":1794376356000,"lat":24.219558,"lon":120.579829,"acc":15.0,"spd":1.3},{"t":1794376361000,"lat":24.219316,"lon":120.579877,"acc":15.0,"spd":1.1},{"t":1794376366000,"lat":24.219284,"lon":120.579718,"acc":15.0,"spd":1.4},{"t":1794376371000,"lat":24.219414,"lon":120.579748,"acc":15.0,"spd":1.6},{"t":1794376376000,"lat":24.219081,"lon":120.579703,"acc":15.0,"spd":1.5},{"t":1794376381000,"lat":24.218967,"lon":120.579709,"acc":15.0,"spd":1.4},{"t":1794376386000,"lat":24.219216,"lon":120.57976,"acc":15.0,"spd":1.5},{"t":1794376391000,"lat":24.219038,"lon":120.579907,"acc":15.0,"spd":1.3},{"t":1794376396000,"lat":24.218924,"lon":120.579714,"acc":15.0,"spd":1.3},{"t":1794376401000,"lat":24.219047,"lon":120.579731,"acc":15.0,"spd":1.7},{"t":1794376406000,"lat":24.219035,"lon":120.579985,"acc":15.0,"spd":1.2},{"t":1794376411000,"lat":24.218579,"lon":120.579849,"acc":15.0,"spd":1.4},{"t":1794376416000,"lat":24.218813,"lon":120.580253,"acc":15.0,"spd":1.4},{"t":1794376421000,"lat":24.218637,"lon":120.580007,"acc":15.0,"spd":1.5},{"t":1794376426000,"lat":24.218577,"lon":120.579878,"acc":15.0,"spd":1.5},{"t":1794376431000,"lat":24.21848,"lon":120.580008,"acc":15.0,"spd":1.3},{"t":1794376436000,"lat":24.218505,"lon":120.580166,"acc":15.0,"spd":1.6},{"t":1794376441000,"lat":24.218264,"lon":120.580013,"acc":15.0,"spd":1.7},{"t":1794376446000,"lat":24.218261,"lon":120.579965,"acc":15.0,"spd":1.2},{"t":1794376451000,"lat":24.218227,"lon":120.580095,"acc":15.0,"spd":1.0},{"t":1794376456000,"lat":24.217964,"lon":120.580111,"acc":15.0,"spd":1.2},{"t":1794376461000,"lat":24.218373,"lon":120.580186,"acc":15.0,"spd":1.2},{"t":1794376466000,"lat":24.217845,"lon":120.580059,"acc":15.0,"spd":1.1},{"t":1794376471000,"lat":24.218086,"lon":120.580177,"acc":15.0,"spd":1.4},{"t":1794376476000,"lat":24.217989,"lon":120.580143,"acc":15.0,"spd":1.4},{"t":1794376481000,"lat":24.217768,"lon":120.580335,"acc":15.0,"spd":1.1},{"t":1794376486000,"lat":24.217749,"lon":120.580107,"acc":15.0,"spd":1.6},{"t":1794376491000,"lat":24.217823,"lon":120.580311,"acc":15.0,"spd":1.5},{"t":1794376496000,"lat":24.217846,"lon":120.580203,"acc":15.0,"spd":1.4},{"t":1794376501000,"lat":24.217608,"lon":120.580172,"acc":15.0,"spd":1.2},{"t":1794376506000,"lat":24.217601,"lon":120.580265,"acc":15.0,"spd":1.5},{"t":1794376511000,"lat":24.217538,"lon":120.580308,"acc":15.0,"spd":1.4},{"t":1794376516000,"lat":24.217553,"lon":120.580261,"acc":15.0,"spd":1.3},{"t":1794376521000,"lat":24.217567,"lon":120.580388,"acc":15.0,"spd":1.6},{"t":1794376526000,"lat":24.217477,"lon":120.580424,"acc":15.0,"spd":1.1},{"t":1794376531000,"lat":24.217302,"lon":120.580272,"acc":15.0,"spd":1.6},{"t":1794376536000,"lat":24.217253,"lon":120.5802,"acc":15.0,"spd":1.4},{"t":1794376541000,"lat":24.21706,"lon":120.580147,"acc":15.0,"spd":1.4},{"t":1794376546000,"lat":24.217093,"lon":120.58034,"acc":15.0,"spd":1.0},{"t":1794376551000,"lat":24.216967,"lon":120.580203,"acc":15.0,"spd":1.7},{"t":1794376556000,"lat":24.2167,"lon":120.580348,"acc":15.0,"spd":1.1},{"t":1794376561000,"lat":24.217012,"lon":120.580253,"acc":15.0,"spd":1.1},{"t":1794376566000,"lat":24.216759,"lon":120.580363,"acc":15.0,"spd":1.5},{"t":1794376571000,"lat":24.216955,"lon":120.580467,"acc":15.0,"spd":1.6},{"t":1794376576000,"lat":24.216619,"lon":120.580243,"acc":15.0,"spd":1.2},{"t":1794376581000,"lat":24.216568,"lon":120.580326,"acc":15.0,"spd":1.7},{"t":1794376586000,"lat":24.216494,"lon":120.580238,"acc":15.0,"spd":1.3},{"t":1794376591000,"lat":24.216502,"lon":120.580378,"acc":15.0,"spd":1.4},{"t":1794376596000,"lat":24.216355,"lon":120.58041,"acc":15.0,"spd":1.4},{"t":1794376601000,"lat":24.216379,"lon":120.580393,"acc":15.0,"spd":1.8},{"t":1794376606000,"lat":24.216406,"lon":120.580205,"acc":15.0,"spd":1.1},{"t":1794376611000,"lat":24.216349,"lon":120.580381,"acc":15.0,"spd":1.4},{"t":1794376616000,"lat":24.216201,"lon":120.580556,"acc":15.0,"spd":1.2},{"t":1794376621000,"lat":24.216134,"lon":120.580361,"acc":15.0,"spd":1.6},{"t":1794376626000,"lat":24.216,"lon":120.580373,"acc":15.0,"spd":1.0},{"t":1794376631000,"lat":24.21582,"lon":120.580565,"acc":15.0,"spd":1.6},{"t":1794376636000,"lat":24.216061,"lon":120.580365,"acc":15.0,"spd":2.0},{"t":1794376641000,"lat":24.215935,"lon":120.580516,"acc":15.0,"spd":1.3},{"t":1794376646000,"lat":24.215796,"lon":120.580591,"acc":15.0,"spd":1.3},{"t":1794376651000,"lat":24.215688,"lon":120.580556,"acc":15.0,"spd":1.6},{"t":1794376656000,"lat":24.215547,"lon":120.580332,"acc":15.0,"spd":1.5},{"t":1794376661000,"lat":24.215601,"lon":120.580549,"acc":15.0,"spd":1.2},{"t":1794376666000,"lat":24.21559,"lon":120.580579,"acc":15.0,"spd":1.3},{"t":1794376671000,"lat":24.215487,"lon":120.580484,"acc":15.0,"spd":1.5},{"t":1794376676000,"lat":24.215532,"lon":120.580456,"acc":15.0,"spd":1.4},{"t":1794376681000,"lat":24.215296,"lon":120.580416,"acc":15.0,"spd":1.4},{"t":1794376686000,"lat":24.21542,"lon":120.580361,"acc":15.0,"spd":1.2},{"t":1794376691000,"lat":24.215198,"lon":120.580504,"acc":15.0,"spd":1.4},{"t":1794376696000,"lat":24.215232,"lon":120.580478,"acc":15.0,"spd":1.2},{"t":1794376701000,"lat":24.215114,"lon":120.58055,"acc":15.0,"spd":1.3},{"t":1794376706000,"lat":24.215037,"lon":120.580591,"acc":15.0,"spd":1.5},{"t":1794376711000,"lat":24.215283,"lon":120.580392,"acc":15.0,"spd":1.4},{"t":1794376716000,"lat":24.214887,"lon":120.580451,"acc":15.0,"spd":1.6},{"t":1794376721000,"lat":24.214936,"lon":120.58056,"acc":15.0,"spd":1.5},{"t":1794376726000,"lat":24.214754,"lon":120.580604,"acc":15.0,"spd":1.5},{"t":1794376731000,"lat":24.214674,"lon":120.580798,"acc":15.0,"spd":1.6},{"t":1794376736000,"lat":24.214763,"lon":120.58041,"acc":15.0,"spd":1.2},{"t":1794376741000,"lat":24.214567,"lon":120.580597,"acc":15.0,"spd":0.9},{"t":1794376746000,"lat":24.214824,"lon":120.580436,"acc":15.0,"spd":1.2},{"t":1794376751000,"lat":24.214378,"lon":120.580723,"acc":15.0,"spd":1.3},{"t":1794376756000,"lat":24.214621,"lon":120.580693,"acc":15.0,"spd":1.2},{"t":1794376761000,"lat":24.214271,"lon":120.580632,"acc":15.0,"spd":1.2},{"t":1794376766000,"lat":24.214242,"lon":120.580568,"acc":15.0,"spd":1.4},{"t":1794376771000,"lat":24.214424,"lon":120.580725,"acc":15.0,"spd":1.3},{"t":1794376776000,"lat":24.214117,"lon":120.580567,"acc":15.0,"spd":1.1},{"t":1794376781000,"lat":24.214013,"lon":120.580735,"acc":15.0,"spd":1.5},{"t":1794376786000,"lat":24.214171,"lon":120.580713,"acc":15.0,"spd":1.3},{"t":1794376791000,"lat":24.214002,"lon":120.5805,"acc":15.0,"spd":1.2},{"t":1794376796000,"lat":24.214023,"lon":120.580667,"acc":15.0,"spd":1.5},{"t":1794376801000,"lat":24.213858,"lon":120.580725,"acc":15.0,"spd":1.3},{"t":1794376806000,"lat":24.213816,"lon":120.580638,"acc":15.0,"spd":1.4},{"t":1794376811000,"lat":24.213686,"lon":120.580874,"acc":15.0,"spd":1.6},{"t":1794376816000,"lat":24.21358,"lon":120.580549,"acc":15.0,"spd":1.4},{"t":1794376821000,"lat":24.213583,"lon":120.580845,"acc":15.0,"spd":1.3},{"t":1794376826000,"lat":24.213548,"lon":120.580768,"acc":15.0,"spd":1.4},{"t":1794376831000,"lat":24.213496,"lon":120.580738,"acc":15.0,"spd":1.7},{"t":1794376836000,"lat":24.213499,"lon":120.58072,"acc":15.0,"spd":1.5},{"t":1794376841000,"lat":24.213428,"lon":120.58067,"acc":15.0,"spd":1.4},{"t":1794376846000,"lat":24.213317,"lon":120.580598,"acc":15.0,"spd":1.5},{"t":1794376851000,"lat":24.21329,"lon":120.580807,"acc":15.0,"spd":1.5},{"t":1794376856000,"lat":24.213191,"lon":120.580967,"acc":15.0,"spd":1.6},{"t":1794376861000,"lat":24.213008,"lon":120.580691,"acc":15.0,"spd":1.5},{"t":1794376866000,"lat":24.212964,"lon":120.58079,"acc":15.0,"spd":1.8},{"t":1794376871000,"lat":24.212865,"lon":120.580875,"acc":15.0,"spd":1.3},{"t":1794376876000,"lat":24.212951,"lon":120.580855,"acc":15.0,"spd":1.3},{"t":1794376881000,"lat":24.212976,"lon":120.580441,"acc":15.0,"spd":1.2},{"t":1794376886000,"lat":24.212757,"lon":120.580653,"acc":15.0,"spd":1.5},{"t":1794376891000,"lat":24.212741,"lon":120.580888,"acc":15.0,"spd":1.6},{"t":1794376896000,"lat":24.212602,"lon":120.581023,"acc":15.0,"spd":1.5},{"t":1794376901000,"lat":24.212568,"lon":120.580663,"acc":15.0,"spd":1.4},{"t":1794376906000,"lat":24.212473,"lon":120.580851,"acc":15.0,"spd":1.0},{"t":1794376911000,"lat":24.212536,"lon":120.580841,"acc":15.0,"spd":1.4},{"t":1794376916000,"lat":24.212577,"lon":120.58085,"acc":15.0,"spd":1.2},{"t":1794376921000,"lat":24.212304,"lon":120.580771,"acc":15.0,"spd":1.6},{"t":1794376926000,"lat":24.212456,"lon":120.580768,"acc":15.0,"spd":1.5},{"t":1794376931000,"lat":24.212244,"lon":120.580739,"acc":15.0,"spd":1.0},{"t":1794376936000,"lat":24.21228,"lon":120.580755,"acc":15.0,"spd":1.4},{"t":1794376941000,"lat":24.211969,"lon":120.58101,"acc":15.0,"spd":1.9},{"t":1794376946000,"lat":24.212064,"lon":120.580849,"acc":15.0,"spd":1.3},{"t":1794376951000,"lat":24.212028,"lon":120.58105,"acc":15.0,"spd":1.3},{"t":1794376956000,"lat":24.212048,"lon":120.580864,"acc":15.0,"spd":1.9},{"t":1794376961000,"lat":24.211912,"lon":120.580932,"acc":15.0,"spd":1.7},{"t":1794376966000,"lat":24.211714,"lon":120.580867,"acc":15.0,"spd":1.5},{"t":1794376971000,"lat":24.211639,"lon":120.580902,"acc":15.0,"spd":1.1},{"t":1794376976000,"lat":24.211601,"lon":120.580797,"acc":15.0,"spd":1.9},{"t":1794376981000,"lat":24.21185,"lon":120.581022,"acc":15.0,"spd":1.5},{"t":1794376986000,"lat":24.211576,"lon":120.581027,"acc":15.0,"spd":1.5},{"t":1794376991000,"lat":24.211416,"lon":120.581103,"acc":15.0,"spd":1.5},{"t":1794376996000,"lat":24.211419,"lon":120.581004,"acc":15.0,"spd":1.5},{"t":1794377001000,"lat":24.211204,"lon":120.581098,"acc":15.0,"spd":1.5},{"t":1794377006000,"lat":24.211409,"lon":120.580993,"acc":15.0,"spd":1.4},{"t":1794377011000,"lat":24.211178,"lon":120.58105,"acc":15.0,"spd":1.2},{"t":1794377016000,"lat":24.211164,"lon":120.580943,"acc":15.0,"spd":1.7},{"t":1794377021000,"lat":24.211069,"lon":120.581091,"acc":15.0,"spd":1.6},{"t":1794377026000,"lat":24.211168,"lon":120.581081,"acc":15.0,"spd":1.3},{"t":1794377031000,"lat":24.210885,"lon":120.580931,"acc":15.0,"spd":1.5},{"t":1794377036000,"lat":24.21093,"lon":120.581208,"acc":15.0,"spd":1.5},{"t":1794377041000,"lat":24.210853,"lon":120.5809,"acc":15.0,"spd":1.5},{"t":1794377046000,"lat":24.210742,"lon":120.581093,"acc":15.0,"spd":1.2},{"t":1794377051000,"lat":24.210805,"lon":120.581125,"acc":15.0,"spd":1.3},{"t":1794377056000,"lat":24.210787,"lon":120.580829,"acc":15.0,"spd":1.2},{"t":1794377061000,"lat":24.21065,"lon":120.580992,"acc":15.0,"spd":1.5},{"t":1794377066000,"lat":24.210514,"lon":120.581014,"acc":15.0,"spd":1.3},{"t":1794377071000,"lat":24.210606,"lon":120.581073,"acc":15.0,"spd":1.5},{"t":1794377076000,"lat":24.210305,"lon":120.58088,"acc":15.0,"spd":1.2},{"t":1794377081000,"lat":24.210339,"lon":120.581093,"acc":15.0,"spd":1.6},{"t":1794377086000,"lat":24.210355,"lon":120.581211,"acc":15.0,"spd":1.5},{"t":1794377091000,"lat":24.210192,"lon":120.581005,"acc":15.0,"spd":1.6},{"t":1794377096000,"lat":24.210371,"lon":120.581114,"acc":15.0,"spd":1.4},{"t":1794377101000,"lat":24.210115,"lon":120.580967,"acc":15.0,"spd":1.4},{"t":1794377106000,"lat":24.209965,"lon":120.581175,"acc":15.0,"spd":1.3},{"t":1794377111000,"lat":24.210142,"lon":120.581207,"acc":15.0,"spd":1.3},{"t":1794377116000,"lat":24.209838,"lon":120.581055,"acc":15.0,"spd":2.0},{"t":1794377121000,"lat":24.209833,"lon":120.581059,"acc":15.0,"spd":1.6},{"t":1794377126000,"lat":24.209734,"lon":120.581179,"acc":15.0,"spd":1.4},{"t":1794377131000,"lat":24.209805,"lon":120.581083,"acc":15.0,"spd":1.2},{"t":1794377136000,"lat":24.209763,"lon":120.580961,"acc":15.0,"spd":1.5},{"t":1794377141000,"lat":24.209714,"lon":120.581334,"acc":15.0,"spd":1.7},{"t":1794377146000,"lat":24.20954,"lon":120.581246,"acc":15.0,"spd":1.6},{"t":1794377151000,"lat":24.209503,"lon":120.581325,"acc":15.0,"spd":1.2},{"t":1794377156000,"lat":24.209511,"lon":120.580995,"acc":15.0,"spd":1.5},{"t":1794377161000,"lat":24.209218,"lon":120.581251,"acc":15.0,"spd":1.5},{"t":1794377166000,"lat":24.209359,"lon":120.581109,"acc":15.0,"spd":1.2},{"t":1794377171000,"lat":24.20914,"lon":120.581406,"acc":15.0,"spd":1.5},{"t":1794377176000,"lat":24.208969,"lon":120.581324,"acc":15.0,"spd":1.4},{"t":1794377181000,"lat":24.209192,"lon":120.581399,"acc":15.0,"spd":1.3},{"t":1794377186000,"lat":24.209039,"lon":120.581454,"acc":15.0,"spd":1.5},{"t":1794377191000,"lat":24.209031,"lon":120.581431,"acc":15.0,"spd":1.3},{"t":1794377196000,"lat":24.209004,"lon":120.581185,"acc":15.0,"spd":1.2},{"t":1794377201000,"lat":24.208995,"lon":120.581596,"acc":15.0,"spd":1.6},{"t":1794377206000,"lat":24.20888,"lon":120.581299,"acc":15.0,"spd":1.4},{"t":1794377211000,"lat":24.208915,"lon":120.581444,"acc":15.0,"spd":1.7},{"t":1794377216000,"lat":24.208698,"lon":120.581379,"acc":15.0,"spd":1.4},{"t":1794377221000,"lat":24.208778,"lon":120.581457,"acc":15.0,"spd":1.6},{"t":1794377226000,"lat":24.208468,"lon":120.581393,"acc":15.0,"spd":1.2},{"t":1794377231000,"lat":24.208485,"lon":120.581448,"acc":15.0,"spd":1.4},{"t":1794377236000,"lat":24.208305,"lon":120.581357,"acc":15.0,"spd":1.7},{"t":1794377241000,"lat":24.208438,"lon":120.581372,"acc":15.0,"spd":1.3},{"t":1794377246000,"lat":24.208338,"lon":120.581483,"acc":15.0,"spd":1.6},{"t":1794377251000,"lat":24.208185,"lon":120.581199,"acc":15.0,"spd":1.2},{"t":1794377256000,"lat":24.208089,"lon":120.581264,"acc":15.0,"spd":1.3},{"t":1794377261000,"lat":24.208155,"lon":120.581454,"acc":15.0,"spd":1.6},{"t":1794377266000,"lat":24.208003,"lon":120.581394,"acc":15.0,"spd":1.3},{"t":1794377271000,"lat":24.207883,"lon":120.581662,"acc":15.0,"spd":1.5},{"t":1794377276000,"lat":24.207889,"lon":120.5814,"acc":15.0,"spd":1.3},{"t":1794377281000,"lat":24.207931,"lon":120.581747,"acc":15.0,"spd":1.2},{"t":1794377286000,"lat":24.207763,"lon":120.581658,"acc":15.0,"spd":1.8},{"t":1794377291000,"lat":24.207935,"lon":120.581552,"acc":15.0,"spd":1.3},{"t":1794377296000,"lat":24.20776,"lon":120.581403,"acc":15.0,"spd":1.1},{"t":1794377301000,"lat":24.207663,"lon":120.581535,"acc":15.0,"spd":1.3},{"t":1794377306000,"lat":24.207694,"lon":120.581683,"acc":15.0,"spd":1.4},{"t":1794377311000,"lat":24.207803,"lon":120.581766,"acc":15.0,"spd":1.2},{"t":1794377316000,"lat":24.207659,"lon":120.581636,"acc":15.0,"spd":1.5},{"t":1794377321000,"lat":24.207227,"lon":120.581505,"acc":15.0,"spd":1.4},{"t":1794377326000,"lat":24.207395,"lon":120.581644,"acc":15.0,"spd":1.2},{"t":1794377331000,"lat":24.207234,"lon":120.581642,"acc":15.0,"spd":1.7},{"t":1794377336000,"lat":24.20716,"lon":120.581808,"acc":15.0,"spd":1.3},{"t":1794377341000,"lat":24.207179,"lon":120.58155,"acc":15.0,"spd":1.6},{"t":1794377346000,"lat":24.206836,"lon":120.581732,"acc":15.0,"spd":1.4},{"t":1794377351000,"lat":24.206789,"lon":120.581895,"acc":15.0,"spd":1.7},{"t":1794377356000,"lat":24.206993,"lon":120.581701,"acc":15.0,"spd":1.5},{"t":1794377361000,"lat":24.206934,"lon":120.581739,"acc":15.0,"spd":1.4},{"t":1794377366000,"lat":24.206683,"lon":120.581554,"acc":15.0,"spd":1.4},{"t":1794377371000,"lat":24.206777,"lon":120.581643,"acc":15.0,"spd":1.4},{"t":1794377376000,"lat":24.206781,"lon":120.581766,"acc":15.0,"spd":1.0},{"t":1794377381000,"lat":24.206699,"lon":120.581777,"acc":15.0,"spd":1.1},{"t":1794377386000,"lat":24.206628,"lon":120.581846,"acc":15.0,"spd":1.1},{"t":1794377391000,"lat":24.206566,"lon":120.581863,"acc":15.0,"spd":1.7},{"t":1794377396000,"lat":24.206617,"lon":120.581723,"acc":15.0,"spd":1.6},{"t":1794377401000,"lat":24.206245,"lon":120.581592,"acc":15.0,"spd":1.5}]} \ No newline at end of file diff --git a/lib/core/bus_repository.dart b/lib/core/bus_repository.dart index 4bae468..8cafe1b 100644 --- a/lib/core/bus_repository.dart +++ b/lib/core/bus_repository.dart @@ -4959,6 +4959,15 @@ class BusRepository { return (normalized == null || normalized.isEmpty) ? null : normalized; } + /// Parses a stop id into the `int` this app keys stops by. + /// + /// Stop ids are 64-bit in the feeds (高雄's look like `100100128221`), so + /// this keeps the full value rather than narrowing it, and every Dart-side + /// map is keyed with the same result. The native side folds whatever it is + /// given into a positive 32-bit key (`foldStopId` in StopIdCodec.kt); the two + /// never need to agree on the same number, only to be self-consistent. + /// + /// Some providers use non-numeric ids (`101-11009`); those hash. int _parseStopId(Object? raw) { if (raw is num) { return raw.toInt(); From 5987fb5f5399e670b2b4f3656ffc5669a1d9b362 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Fri, 13 Nov 2026 14:40:40 +0800 Subject: [PATCH 02/11] fix(android): address trip monitor review findings Four regressions from the boarding-detection rewrite, each with a regression test that fails when the fix is reverted. - Fix age: Location.time (epoch) was subtracted from elapsedRealtime (since boot), so every cached fix looked current. Age is now read off the monotonic clock, the session's seed position carries the time Flutter measured it, and only a fresh fix may anchor the boarding stop. - Boarded without a destination: the snapshot always reported "waiting", so the destination prompt never fired. It now reports the ride, and choosing a stop afterwards continues that ride instead of starting a new wait. The route screen re-checks the paused state when a destination is chosen so the new session is actually sent. - Declining a bus no longer disables detection for the trip. Only the declined vehicle is set aside (20 min), movement before the answer no longer counts, and the next departure is asked about again. - Launch payloads carry the full-width stop id. The folded Int is kept for native feed joins only; widget, trip and smart-route notification taps and pinned shortcuts send the id Flutter holds. Int extras from shortcuts pinned by older builds are still read. --- .../taiwanbus/flutter/AppLaunchConstants.kt | 35 +- .../flutter/FavoriteGroupWidgetProvider.kt | 53 ++- .../taiwanbus/flutter/MainActivity.kt | 5 +- .../avianjay/taiwanbus/flutter/RideFixTime.kt | 33 ++ .../flutter/RouteTripMonitorService.kt | 126 +++++- .../flutter/SmartRouteNotificationWorker.kt | 20 +- .../avianjay/taiwanbus/flutter/StopIdCodec.kt | 25 ++ .../taiwanbus/flutter/TripRideDetector.kt | 124 +++++- .../taiwanbus/flutter/AppLaunchStopIdTest.kt | 228 ++++++++++ .../taiwanbus/flutter/RideFixTimeTest.kt | 68 +++ .../flutter/RouteTripMonitorServiceTest.kt | 207 +++++++++ .../flutter/TripRideDetectorDeclineTest.kt | 404 ++++++++++++++++++ .../flutter/TripRideDetectorReplayTest.kt | 20 +- lib/core/android_trip_monitor.dart | 7 + lib/screens/route_detail_screen.dart | 12 + test/android_trip_monitor_test.dart | 64 ++- test/app_routes_test.dart | 22 + test/route_detail_screen_loading_test.dart | 56 +++ 18 files changed, 1439 insertions(+), 70 deletions(-) create mode 100644 android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorDeclineTest.kt diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt index ab02486..8ee0798 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchConstants.kt @@ -26,9 +26,9 @@ object AppLaunchConstants { routeKey: Int, routeId: String? = null, pathId: Int? = null, - stopId: Int? = null, + stopId: Long? = null, destinationPathId: Int? = null, - destinationStopId: Int? = null, + destinationStopId: Long? = null, ): Intent { return Intent(context, MainActivity::class.java).apply { putExtra(EXTRA_TARGET, TARGET_ROUTE_DETAIL) @@ -100,9 +100,12 @@ object AppLaunchConstants { if (intent.hasExtra(EXTRA_PATH_ID)) { payload["pathId"] = intent.getIntExtra(EXTRA_PATH_ID, 0) } - if (intent.hasExtra(EXTRA_STOP_ID)) { - payload["stopId"] = intent.getIntExtra(EXTRA_STOP_ID, 0) - } + // Stop ids are sent at full width, because Dart keys stops by + // the raw feed id: 高雄's exceed Int, and narrowing them here + // would name a different stop than the app's models hold. The + // native-only fold (`foldStopId`) is for joining the live + // feeds, not for identifying a stop to Flutter. + stopIdExtra(intent, EXTRA_STOP_ID)?.let { payload["stopId"] = it } val destinationPathId = intent.getIntExtra( EXTRA_DESTINATION_PATH_ID, Int.MIN_VALUE, @@ -110,13 +113,8 @@ object AppLaunchConstants { if (destinationPathId != Int.MIN_VALUE) { payload["destinationPathId"] = destinationPathId } - val destinationStopId = intent.getIntExtra( - EXTRA_DESTINATION_STOP_ID, - Int.MIN_VALUE, - ) - if (destinationStopId != Int.MIN_VALUE) { - payload["destinationStopId"] = destinationStopId - } + stopIdExtra(intent, EXTRA_DESTINATION_STOP_ID) + ?.let { payload["destinationStopId"] = it } payload } @@ -145,6 +143,19 @@ object AppLaunchConstants { } } + /** + * Reads a stop id extra as a `Long` whichever width it was stored at. + * + * New intents store a `Long`. Shortcuts pinned to the home screen before + * that keep the `Int` they were created with for as long as they exist, and + * `getLongExtra` answers its default for an extra of another type — which + * would quietly open those shortcuts with no stop selected. + */ + @Suppress("DEPRECATION") + private fun stopIdExtra(intent: Intent, key: String): Long? { + return (intent.extras?.get(key) as? Number)?.toLong() + } + private fun extractAuthCallbackPayload(uri: android.net.Uri?): Map? { uri ?: return null if (uri.scheme?.lowercase() != "yabus" || uri.host?.lowercase() != "auth-callback") { diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt index dd245c5..9800e20 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/FavoriteGroupWidgetProvider.kt @@ -390,7 +390,8 @@ object FavoriteGroupWidgetSupport { ) } - private fun createItemPendingIntent( + /** The tap intent for one widget row. Internal so a test can read it back. */ + internal fun createItemPendingIntent( context: Context, item: FavoriteWidgetItem, ): PendingIntent { @@ -409,9 +410,12 @@ object FavoriteGroupWidgetSupport { routeKey = item.routeKey, routeId = item.routeId, pathId = item.pathId.takeIf { item.type == "boarding" }, - stopId = item.stopId.takeIf { item.type == "boarding" }, + // The full-width id, not the folded one: Flutter compares this + // against the ids its own stop models carry, and a folded + // 高雄 id matches nothing there. + stopId = item.launchStopId.takeIf { item.type == "boarding" }, destinationPathId = item.destinationPathId, - destinationStopId = item.destinationStopId, + destinationStopId = item.launchDestinationStopId, ).apply { data = Uri.parse( "yabus://route/${item.provider}/${item.routeKey}/${Uri.encode(item.routeId.orEmpty())}", @@ -434,9 +438,14 @@ object FavoriteGroupWidgetSupport { val raw = runCatching { preferences.getString(FAVORITE_GROUPS_KEY, null) ?: preferences.getString(FAVORITE_GROUPS_FALLBACK_KEY, null) - }.getOrNull() ?: return emptyMap() + }.getOrNull() + return parseFavoriteGroups(raw) + } + + /** Reads the favourites Dart saved. Internal so a test can feed it a config. */ + internal fun parseFavoriteGroups(raw: String?): Map> { val root = try { - JSONObject(raw) + JSONObject(raw ?: return emptyMap()) } catch (_: Exception) { return emptyMap() } @@ -449,9 +458,13 @@ object FavoriteGroupWidgetSupport { val item = groupArray.optJSONObject(index) ?: continue val type = item.optString("type", "boarding").trim().ifEmpty { "boarding" } val routeKey = item.optInt("routeKey", 0) - // The config is written by Dart with the raw feed id, which can - // exceed Int for 高雄; fold it the same way the live feed does. - val stopId = foldStopId(item.opt("stopId")) + // The config is written by Dart with the id as Dart holds it, + // which exceeds Int for 高雄. It is needed twice, as two + // different numbers: folded the way the live feed is, for the + // ETA lookup; and at full width, for the tap that launches + // back into Flutter. + val dartStopId = item.opt("stopId") + val stopId = foldStopId(dartStopId) val routeId = item.optString("routeId", "").trim().takeIf { it.isNotEmpty() } val stationId = item.optString("stationId", "").trim() val valid = when (type) { @@ -463,19 +476,15 @@ object FavoriteGroupWidgetSupport { if (!valid) { continue } - val destinationStopId = foldStopId(item.opt("destinationStopId")) - .takeIf { it > 0 } - val destinationPathId = if (destinationStopId == null) { - null - } else { - item.optInt("destinationPathId", item.optInt("pathId", 0)) - } + val dartDestinationStopId = item.opt("destinationStopId") + val hasDestination = foldStopId(dartDestinationStopId) > 0 groupItems += FavoriteWidgetItem( type = type, provider = item.optString("provider", "twn"), routeKey = routeKey, pathId = item.optInt("pathId", 0), stopId = stopId, + launchStopId = launchStopId(dartStopId).takeIf { stopId > 0 }, routeId = routeId, routeName = item.optString("routeName", ""), routeDescription = item.optString("routeDescription", "") @@ -484,8 +493,13 @@ object FavoriteGroupWidgetSupport { stopName = item.optString("stopName", ""), stationId = stationId, stationName = item.optString("stationName", ""), - destinationPathId = destinationPathId, - destinationStopId = destinationStopId, + destinationPathId = if (hasDestination) { + item.optInt("destinationPathId", item.optInt("pathId", 0)) + } else { + null + }, + launchDestinationStopId = launchStopId(dartDestinationStopId) + .takeIf { hasDestination }, destinationStopName = item.optString("destinationStopName", "") .trim() .takeIf { it.isNotEmpty() }, @@ -795,7 +809,10 @@ data class FavoriteWidgetItem( val provider: String, val routeKey: Int, val pathId: Int, + /** Folded, as the live feeds are: only usable for looking up ETAs. */ val stopId: Int, + /** Full-width, as Flutter holds it: what a tap is launched with. */ + val launchStopId: Long?, val routeId: String?, val routeName: String, val routeDescription: String?, @@ -803,7 +820,7 @@ data class FavoriteWidgetItem( val stationId: String, val stationName: String, val destinationPathId: Int?, - val destinationStopId: Int?, + val launchDestinationStopId: Long?, val destinationStopName: String?, ) { val identity: String diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt index 11a161c..b91cb62 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/MainActivity.kt @@ -306,7 +306,10 @@ class MainActivity : FlutterActivity() { val routeKey = call.argument("routeKey") val routeId = call.argument("routeId")?.trim().orEmpty() val pathId = call.argument("pathId") - val stopId = call.argument("stopId") + // Stop ids can exceed Int (高雄's are 64-bit), and the channel decodes a + // Dart int to Integer or Long depending on its magnitude, so both are + // accepted rather than letting the larger ones read back as null. + val stopId = call.argument("stopId")?.toLong() val routeName = call.argument("routeName")?.trim().orEmpty() val stopName = call.argument("stopName")?.trim().orEmpty() val stationId = call.argument("stationId")?.trim().orEmpty() diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt new file mode 100644 index 0000000..342e52a --- /dev/null +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTime.kt @@ -0,0 +1,33 @@ +package tw.avianjay.taiwanbus.flutter + +import android.location.Location +import android.os.SystemClock + +/** + * When [location] was measured, on the wall clock the ride detector runs on. + * + * A fix's age is read off the monotonic clock, the only one a fix and "now" are + * guaranteed to share: `Location.time` may come from the satellites, the + * network or the system clock, and none of those has to agree with + * `System.currentTimeMillis()`. Taking that age off the current wall-clock time + * puts a fix cached five minutes ago five minutes in the past — where the + * detector can see it is stale — however the device's clock happens to be set. + * + * A location with no monotonic stamp did not come from the platform (the + * session's seed position is built by hand). Its own `time` is used when it has + * a believable one, and it counts as current otherwise. + */ +internal fun rideFixTimestampMs( + location: Location, + nowWallMs: Long = System.currentTimeMillis(), + nowElapsedNanos: Long = SystemClock.elapsedRealtimeNanos(), +): Long { + val fixElapsedNanos = location.elapsedRealtimeNanos + if (fixElapsedNanos > 0L) { + val ageMs = ((nowElapsedNanos - fixElapsedNanos) / NANOS_PER_MILLI).coerceAtLeast(0L) + return nowWallMs - ageMs + } + return location.time.takeIf { it in 1L..nowWallMs } ?: nowWallMs +} + +private const val NANOS_PER_MILLI = 1_000_000L diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index d820846..3c0a550 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -109,11 +109,9 @@ class RouteTripMonitorService : Service() { RideGeoPoint( latitude = location.latitude, longitude = location.longitude, - // Fixes can be minutes old when the provider hands back a - // cached position; ageing them here keeps the whole detector on - // the wall clock the rest of the service uses. - timestampMs = System.currentTimeMillis() - - (SystemClock.elapsedRealtime() - location.time).coerceAtLeast(0L), + // The fix's real age, so that a cached one from minutes ago is + // recognised as stale instead of passing for the present. + timestampMs = rideFixTimestampMs(location), accuracyMeters = if (location.hasAccuracy()) location.accuracy else 0f, speedMetersPerSecond = location.speed.takeIf { location.hasSpeed() && it > 0f }, ), @@ -209,9 +207,12 @@ class RouteTripMonitorService : Service() { stopTracking() return START_NOT_STICKY } + val now = System.currentTimeMillis() synchronized(detectorLock) { - detector?.declineBoarding() - latestRide = detector?.snapshot(System.currentTimeMillis()) ?: RideSnapshot() + // Only the bus that was asked about is set aside; the next + // one still gets detected and asked about in its turn. + detector?.declineBoarding(now) + latestRide = detector?.snapshot(now) ?: RideSnapshot() } boardingAlertSent = false destinationSetupPromptSent = false @@ -244,6 +245,12 @@ class RouteTripMonitorService : Service() { stopTracking() return START_NOT_STICKY } + val startedAtMs = System.currentTimeMillis() + val continuesBoardedRide = !hadDetector && continuesBoardedRide( + paused = AppRuntimeStateStore.loadPausedTripMonitor(this), + session = parsedSession, + nowMs = startedAtMs, + ) session = parsedSession appInForeground = parsedSession.appInForeground val providedStops = parsedSession.stops.mapNotNull { stop -> @@ -287,9 +294,19 @@ class RouteTripMonitorService : Service() { overshootAlertSent = false cancelBoardingCheckPrompt() synchronized(detectorLock) { - detector = createDetector(parsedSession) + detector = createDetector(parsedSession).also { created -> + if (continuesBoardedRide) { + // Choosing where to get off, in answer to "you + // have boarded", is the rider confirming it. + created.confirmBoardedByUser(startedAtMs) + } + } latestRide = RideSnapshot() } + if (continuesBoardedRide) { + boardingAlertSent = true + AppRuntimeStateStore.clearPausedTripMonitor(this) + } refreshRideSnapshot(System.currentTimeMillis()) // Only the very first update seeds the detector from the // session's stale position; a later re-seed would replay an @@ -325,6 +342,32 @@ class RouteTripMonitorService : Service() { private fun detectorExists(): Boolean = synchronized(detectorLock) { detector != null } + /** + * Whether [session] is the ride that was paused for want of a destination, + * now given one. + * + * On board with nowhere to get off there is nothing to remind the rider of, + * so the monitor asks them to choose a stop and pauses. Their answer starts + * it again — on the same bus. Coming back as "waiting to board" would throw + * the ride away at the very moment the reminders it was chosen for become + * possible, and mid-route there may be nothing left to detect it from. + */ + private fun continuesBoardedRide( + paused: TripMonitorPauseState?, + session: TrackingSession, + nowMs: Long, + ): Boolean { + paused ?: return false + return paused.reason == PAUSE_REASON_BOARDED_NO_DESTINATION && + paused.destinationStopId == null && + session.destinationStopId != null && + paused.provider == session.provider && + paused.routeKey == session.routeKey && + paused.pathId == session.pathId && + paused.boardingStopId == session.boardingStopId && + nowMs - paused.pausedAtMs in 0L..BOARDED_RIDE_RESUME_WINDOW_MS + } + override fun onDestroy() { mainHandler.removeCallbacksAndMessages(null) runCatching { @@ -409,6 +452,10 @@ class RouteTripMonitorService : Service() { Location("trip_monitor_session").apply { this.latitude = latitude this.longitude = longitude + // The route screen's last position can be minutes old by + // the time the trip starts; carrying when it was taken lets + // the detector judge that for itself. + session.initialLocationTimestampMs?.let { time = it } } } } @@ -751,6 +798,35 @@ class RouteTripMonitorService : Service() { }?.let { (index - it).coerceAtLeast(0) } } + if (ride.hasBoarded && destinationStop == null) { + // On board with nowhere to get off yet. This comes before every + // waiting branch — including the one for a trip with neither end + // chosen — because a confirmed ride has to be reported as one: + // that is what makes the destination-setup prompt fire. + val travelIndex = ride.progressStopIndex.takeIf { it >= 0 } + ?: ride.nearestStopIndexOnBoard(liveStops, session.stops) + ?: nearestIndex + val currentStop = session.stops.getOrNull(travelIndex) + return TrackingSnapshot( + title = session.routeName, + content = if (currentStop != null) { + "已上車 · 最近站牌 ${currentStop.stopName}" + } else { + "已上車 · 追蹤中" + }, + subText = buildOnboardTripSubText( + currentStop?.stopName ?: session.pathName.ifBlank { "乘車中" }, + ), + progressMax = null, + progressValue = null, + shortCriticalText = null, + hasBoarded = true, + boardingName = boardingStop?.stopName, + boardingVehicleId = ride.trackedVehicleId ?: boardingVehicleId, + nearestStopDistanceMeters = nearestStopDistanceMeters, + ) + } + if (boardingStop == null && destinationStop == null) { // Neither end of the trip is known: just report the nearest stop. return TrackingSnapshot( @@ -1726,7 +1802,11 @@ class RouteTripMonitorService : Service() { provider = session.provider, routeKey = session.routeKey, pathId = session.pathId, - stopId = session.destinationStopId ?: session.stops.firstOrNull()?.stopId ?: 0, + // The full-width id, because Dart compares it against the ids its + // own stop models carry: a folded 高雄 id matches nothing there, so + // tapping the notification would not select the stop. + stopId = session.destinationLaunchStopId + ?: session.stops.firstOrNull()?.launchStopId?.takeIf { it > 0L }, ) return PendingIntent.getActivity( this, @@ -2063,6 +2143,13 @@ class RouteTripMonitorService : Service() { private const val LOCATION_UPDATE_INTERVAL_MS = 12_000L private const val LOCATION_MIN_UPDATE_INTERVAL_MS = 6_000L private const val BACKGROUND_AUTO_PAUSE_GRACE_MS = 90_000L + + /** + * How long after pausing for a destination the same ride may still be + * picked up. A city ride is rarely longer; past this the rider is + * re-detected from their movement like anyone else. + */ + private const val BOARDED_RIDE_RESUME_WINDOW_MS = 60 * 60 * 1000L private const val WAKE_LOCK_TAG = "YABus::TripMonitor" // The lock is renewed on every start, so this only bounds a session that // was never cleaned up; it is not the expected trip length. @@ -2090,10 +2177,16 @@ class RouteTripMonitorService : Service() { val stops = mutableListOf() for (index in 0 until stopsJson.length()) { val stop = stopsJson.optJSONObject(index) ?: continue + // The id as Dart holds it, which is 64-bit for 高雄. It is + // needed twice, as two different numbers: folded, to key + // the live feeds; and at full width, for anything a + // notification tap carries back into Flutter. + val dartStopId = stop.opt("stopId") stops += TrackingStop( // Must match how the live feed folds the same id, or the // two maps never line up for 64-bit ids (高雄). - stopId = foldStopId(stop.opt("stopId")), + stopId = foldStopId(dartStopId), + launchStopId = launchStopId(dartStopId), rawStopId = stop.optString("rawStopId", "") .trim().takeIf { it.isNotEmpty() && it != "null" }, stopName = stop.optString("stopName", ""), @@ -2127,6 +2220,8 @@ class RouteTripMonitorService : Service() { .takeUnless { it.isNaN() }, initialLongitude = root.optDouble("initialLongitude", Double.NaN) .takeUnless { it.isNaN() }, + initialLocationTimestampMs = root.optLong("initialLocationTimestampMs", 0L) + .takeIf { it > 0L }, boardingStopId = foldStopId(root.opt("boardingStopId")) .takeIf { it > 0 }, boardingStopName = root.optString("boardingStopName", "") @@ -2135,6 +2230,10 @@ class RouteTripMonitorService : Service() { .takeIf { it > 0 }, destinationStopName = root.optString("destinationStopName", "") .takeIf { it.isNotBlank() }, + boardingLaunchStopId = launchStopId(root.opt("boardingStopId")) + .takeIf { it > 0L }, + destinationLaunchStopId = launchStopId(root.opt("destinationStopId")) + .takeIf { it > 0L }, stops = stops.sortedBy { stop -> stop.sequence }, ) } catch (_: Exception) { @@ -2224,6 +2323,11 @@ data class TrackingSession( val destinationStopName: String?, val stops: List, val familyRouteIds: List = emptyList(), + /** Full-width ids as Flutter holds them; only for launch payloads. */ + val boardingLaunchStopId: Long? = null, + val destinationLaunchStopId: Long? = null, + /** Epoch milliseconds of the fix behind the initial position, when known. */ + val initialLocationTimestampMs: Long? = null, ) { val realtimeRouteIds: List get() = (listOfNotNull(routeId) + familyRouteIds).map { it.trim() } @@ -2238,6 +2342,8 @@ data class TrackingStop( val lon: Double, val rawStopId: String? = null, val liveData: LiveStopState? = null, + /** Full-width id as Flutter holds it; only for launch payloads. */ + val launchStopId: Long = 0L, ) data class LiveStopState( diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt index b5e1b5b..f9bf43c 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/SmartRouteNotificationWorker.kt @@ -369,6 +369,7 @@ internal object SmartRouteNotificationSupport { bestStop = SmartNearestStop( pathId = stop.pathId, stopId = stop.stopId, + launchStopId = stop.launchStopId, stopName = stop.stopName, distanceMeters = distance, ) @@ -452,7 +453,9 @@ internal object SmartRouteNotificationSupport { provider = profile.provider, routeKey = profile.routeKey, pathId = nearestStop.pathId, - stopId = nearestStop.stopId, + // Full-width: Flutter matches this against its own stop ids, and + // the folded key would select nothing there. + stopId = nearestStop.launchStopId, ), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -620,12 +623,15 @@ internal object SmartRouteNotificationSupport { arrayOf(routeKey.toString()), ).use { cursor -> while (cursor.moveToNext()) { + // stop_id is a TEXT column and 高雄 ids exceed Int. It is needed + // twice, as two different numbers: folded, to key the live + // feed; and at full width, for the notification tap that goes + // back into Flutter, whose stop models hold the unfolded id. + val storedStopId = cursor.getString(1) result += SmartRouteStop( pathId = cursor.getInt(0), - // stopid is a TEXT column and 高雄 ids exceed Int; reading it - // with getInt would truncate the key the live feed is folded - // with, and the two would never meet. - stopId = foldStopId(cursor.getString(1)), + stopId = foldStopId(storedStopId), + launchStopId = launchStopId(storedStopId), stopName = cursor.getString(2).orEmpty(), sequence = cursor.getInt(3), lon = cursor.getDouble(4), @@ -790,7 +796,10 @@ internal data class SmartRouteData( internal data class SmartRouteStop( val pathId: Int, + /** Folded, as the live feeds are: only usable for looking up ETAs. */ val stopId: Int, + /** Full-width, as Flutter holds it: what a notification tap may carry. */ + val launchStopId: Long, val stopName: String, val sequence: Int, val lon: Double, @@ -800,6 +809,7 @@ internal data class SmartRouteStop( internal data class SmartNearestStop( val pathId: Int, val stopId: Int, + val launchStopId: Long, val stopName: String, val distanceMeters: Double, ) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt index 66027f2..4c3d031 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/StopIdCodec.kt @@ -43,3 +43,28 @@ internal fun foldStopId(raw: Any?): Int { } return hash } + +/** + * The stop id as Flutter holds it, for launch payloads (widget taps, notification + * taps, pinned shortcuts). + * + * Dart keys stops by the id it read from the feed: numeric ids stay 64-bit + * (高雄's `100100128221` would not fit in an `Int`), non-numeric ones hash. The + * native fold is only for joining the live feeds and must not be sent to Dart — + * a folded id names no stop the Flutter side knows, so the tap would silently + * open the wrong stop or none at all. This returns the value Dart itself would + * have computed for the same raw id. + */ +internal fun launchStopId(raw: Any?): Long { + val text = when (raw) { + is Number -> raw.toLong().toString() + else -> raw?.toString()?.trim().orEmpty() + } + if (text.isEmpty() || text.equals("null", ignoreCase = true)) { + return 0L + } + text.toLongOrNull()?.let { return it } + // Non-numeric ids hash on both sides, and both hashes start at 17, so the + // folded value already is what Dart computed. + return foldStopId(raw).toLong() +} diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index 6fb8275..23f5101 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -105,7 +105,24 @@ internal class TripRideDetector( private var boarded = false private var evidence = BoardingEvidence.NONE private var trackedVehicleId: String? = null - private var autoDetectionSuppressed = false + + /** + * Vehicles the rider has answered "not this one" about, each with the moment + * of that answer. The answer is about one bus, not about the trip: only the + * vehicle it named is set aside, and only for [REJECTED_VEHICLE_TTL_MS], so + * the next bus both raises the question and gets detected as normal. See + * [declineBoarding]. + */ + private val rejectedAt = HashMap() + private var lastDeclineAtMs = 0L + + /** + * The departure behind the last "not yet" answer: when it was registered, + * and whether the bus it belonged to is still unknown because only the ETA + * had reported it. + */ + private var declinedDepartureAtMs = 0L + private var declinedDepartureUnattributed = false private var etaArrivingAtMs = 0L private var pendingCheckSinceMs = 0L @@ -160,11 +177,6 @@ internal class TripRideDetector( while (userTrack.size > MAX_USER_SAMPLES) { userTrack.removeFirst() } - if (anchoredBoardingIndex < 0) { - // Used only when no boarding stop was chosen: the trip starts - // wherever the user actually is. - anchoredBoardingIndex = line.nearestStopIndex(projection.alongMeters) - } return true } @@ -223,29 +235,62 @@ internal class TripRideDetector( fun confirmBoardedByUser(nowMs: Long) { boarded = true evidence = BoardingEvidence.USER_CONFIRMED - autoDetectionSuppressed = false + rejectedAt.clear() trackedVehicleId = pendingCheckVehicleId ?: vehicleJustPastBoardingStop(nowMs) clearPendingCheck() } /** - * The user says they are not on the bus. Automatic detection stays off for - * the rest of the trip: whatever raised the false positive is still in the - * feed, so re-deriving it a minute later would only contradict the user - * again. They can still confirm the ride by hand. + * The user says they are not on the bus the app thinks they are on. + * + * The answer is about one bus, not about the trip, so only what was just + * asserted is set aside: the vehicle of the pending question if there is + * one, else the vehicle the detected ride named, for + * [REJECTED_VEHICLE_TTL_MS]; and the rider's own movement up to this + * moment, which no longer counts as evidence of a ride. The next bus raises + * its own question and is detected exactly as this one was. + * + * Switching detection off for the rest of the trip instead would leave the + * rider sitting on their real bus with no boarding alert and no destination + * reminders, so one repeated question is the better failure. */ - fun declineBoarding() { + fun declineBoarding(nowMs: Long) { + val answeredAQuestion = pendingCheckSinceMs != 0L + val rejected = pendingCheckVehicleId ?: trackedVehicleId boarded = false evidence = BoardingEvidence.NONE trackedVehicleId = null - autoDetectionSuppressed = true + lastDeclineAtMs = nowMs + declinedDepartureAtMs = if (answeredAQuestion) pendingCheckSinceMs else nowMs + declinedDepartureUnattributed = answeredAQuestion && rejected == null + if (rejected != null) { + rejectedAt[rejected] = nowMs + } clearPendingCheck() } + /** + * Whether [vehicleId] is a bus the rider has already turned down and whose + * rejection is still in force. Entries lapse after + * [REJECTED_VEHICLE_TTL_MS] so a vehicle that comes round again is treated + * as new. + */ + private fun isRejected(vehicleId: String, nowMs: Long): Boolean { + val declinedAt = rejectedAt[vehicleId] ?: return false + return nowMs - declinedAt <= REJECTED_VEHICLE_TTL_MS + } + fun snapshot(nowMs: Long): RideSnapshot { prune(nowMs) val latestUser = userTrack.lastOrNull() val usableUser = latestUser?.takeIf { nowMs - it.timestampMs <= LOCATION_STALE_MS } + if (anchoredBoardingIndex < 0 && usableUser != null) { + // Used only when no boarding stop was chosen: the trip starts + // wherever the user actually is. Only a current fix can say that — + // a cached one from minutes ago would pin the whole trip to a stop + // the user may have left long before. + anchoredBoardingIndex = line.nearestStopIndex(usableUser.alongMeters) + } val boardingStop = boardingIndex val boardingAlong = if (boardingStop >= 0) line.alongOfStop(boardingStop) else null val vehicles = liveVehicles(nowMs) @@ -253,7 +298,7 @@ internal class TripRideDetector( if (boardingAlong != null) { trackDepartures(vehicles, boardingAlong, nowMs) } - if (!boarded && !autoDetectionSuppressed) { + if (!boarded) { val coMovingVehicleId = detectCoMovement(nowMs) if (coMovingVehicleId != null) { markBoarded(BoardingEvidence.BUS_CO_MOVEMENT, coMovingVehicleId) @@ -339,6 +384,9 @@ internal class TripRideDetector( } val pairs = ArrayList() busTracks.forEach { (vehicleId, track) -> + if (isRejected(vehicleId, nowMs)) { + return@forEach + } pairs.clear() track.forEach { report -> if (report.receivedAtMs < nowMs - COMOVE_WINDOW_MS) { @@ -401,13 +449,21 @@ internal class TripRideDetector( * Layer B. 500 m in 200 s is 2.5 m/s — above a brisk walk and above a bus * that sat in traffic for the whole window — so a pedestrian following the * same street from the stop is not mistaken for a passenger. + * + * Travel that happened before the rider last answered "not yet" does not + * count: that is the movement the answer was about, and letting it count + * again would overrule the answer a moment after it was given. Evidence + * starts accumulating from scratch instead. */ private fun detectSelfProgress( nowMs: Long, boardingAlong: Double, latestUser: UserSample, ): Boolean { - val windowStartMs = nowMs - SELF_PROGRESS_WINDOW_MS + if (latestUser.timestampMs < lastDeclineAtMs) { + return false + } + val windowStartMs = maxOf(nowMs - SELF_PROGRESS_WINDOW_MS, lastDeclineAtMs) val origin = userTrack.firstOrNull { sample -> sample.timestampMs >= windowStartMs && sample.accuracyMeters <= SELF_PROGRESS_MAX_ACCURACY_METERS && @@ -466,9 +522,34 @@ internal class TripRideDetector( } private fun registerDeparture(vehicleId: String?, nowMs: Long) { - if (boarded || autoDetectionSuppressed) { + if (boarded) { + return + } + if (vehicleId != null && isRejected(vehicleId, nowMs)) { + // The rider already said this bus was not theirs. Asking again + // about the same one is the noise the rejection exists to avoid; + // every other vehicle is untouched. return } + if (declinedDepartureAtMs != 0L && nowMs - declinedDepartureAtMs < DEPARTURE_DEDUPE_MS) { + // One departure reaches the app twice — from the position feed and + // from the ETA rolling over — up to a minute and a half apart, and + // this one has already been answered. The window runs from the + // departure, not from the answer: a rider may take minutes to + // reply, and what leaves the stop after that is the next bus. + if (vehicleId == null) { + return + } + if (declinedDepartureUnattributed) { + // The question came from the ETA alone, so the answer had no + // vehicle to be held against. This is that bus, now named. + declinedDepartureUnattributed = false + rejectedAt[vehicleId] = lastDeclineAtMs + return + } + // A different vehicle this close behind is a second bus, and gets + // a question of its own. + } if (pendingCheckSinceMs != 0L && nowMs - pendingCheckSinceMs < DEPARTURE_DEDUPE_MS) { // The position feed and the ETA rollover both report the same bus. pendingCheckVehicleId = pendingCheckVehicleId ?: vehicleId @@ -496,7 +577,7 @@ internal class TripRideDetector( usableUser: UserSample?, boardingStop: Int, ): Boolean { - if (boarded || autoDetectionSuppressed || pendingCheckSinceMs == 0L) { + if (boarded || pendingCheckSinceMs == 0L) { return false } val ageMs = nowMs - pendingCheckSinceMs @@ -626,6 +707,7 @@ internal class TripRideDetector( } } busTracks.entries.removeAll { it.value.isEmpty() } + rejectedAt.entries.removeAll { nowMs - it.value > REJECTED_VEHICLE_TTL_MS } } private fun indexOfStop(stopId: Int?): Int { @@ -710,6 +792,14 @@ internal class TripRideDetector( const val BOARDING_STOP_ABANDONED_RADIUS_METERS = 400.0 const val BOARDING_PROMPT_DELAY_MS = 40_000L const val BOARDING_CHECK_MAX_AGE_MS = 300_000L + + /** + * How long a turned-down vehicle stays set aside. Long enough to cover + * the wait for the next bus being asked about, short enough that a + * vehicle which really is the rider's — a late arrival, or the same + * vehicle on its next trip — is not written off for the rest of the day. + */ + const val REJECTED_VEHICLE_TTL_MS = 1_200_000L const val STAYED_BEHIND_MAX_FIX_AGE_MS = 30_000L const val STAYED_BEHIND_MAX_ACCURACY_METERS = 50f const val STAYED_BEHIND_MAX_MOVE_METERS = 60.0 diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt new file mode 100644 index 0000000..d87f3a2 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/AppLaunchStopIdTest.kt @@ -0,0 +1,228 @@ +package tw.avianjay.taiwanbus.flutter + +import android.app.Application +import android.app.NotificationManager +import android.content.Intent +import android.database.sqlite.SQLiteDatabase +import android.location.Location +import java.io.File +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * A stop the user taps has to arrive in Flutter as the id Flutter knows. + * + * Stop ids are 64-bit in some feeds (高雄's are 11-12 digits), and Dart keys its + * stops by the raw value. The native side folds ids to a positive `Int` to join + * the live feeds, and that folded number used to be what widget and + * notification taps carried too — naming no stop the Flutter side has, so a + * 高雄 favourite opened its route with nothing selected. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AppLaunchStopIdTest { + private val application: Application get() = RuntimeEnvironment.getApplication() + + @Test + fun theFullWidthIdSurvivesTheLaunchPayload() { + assertNotEquals( + "this id only proves anything if folding changes it", + RAW_STOP_ID, + foldStopId(RAW_STOP_ID).toLong(), + ) + + val payload = launchPayloadOf( + AppLaunchConstants.createRouteDetailIntent( + context = application, + provider = "khh", + routeKey = 100, + routeId = "KHH100", + pathId = 0, + stopId = RAW_STOP_ID, + destinationPathId = 0, + destinationStopId = RAW_STOP_ID + 30, + ), + ) + + assertEquals("khh", payload["provider"]) + assertEquals(RAW_STOP_ID, payload.stopId("stopId")) + assertEquals(RAW_STOP_ID + 30, payload.stopId("destinationStopId")) + } + + @Test + fun idsThatFitInAnIntAreUnchanged() { + // 台中 and 雙北 ids are small, and must keep arriving exactly as before. + val payload = launchPayloadOf( + AppLaunchConstants.createRouteDetailIntent( + context = application, + provider = "txg", + routeKey = 1, + pathId = 0, + stopId = 13567L, + ), + ) + + assertEquals(13567L, payload.stopId("stopId")) + } + + @Test + fun aMissingStopIdIsStillAbsentFromThePayload() { + val payload = launchPayloadOf( + AppLaunchConstants.createRouteDetailIntent( + context = application, + provider = "txg", + routeKey = 1, + ), + ) + + assertFalse("no stop chosen means no stop in the payload", payload.containsKey("stopId")) + assertFalse(payload.containsKey("destinationStopId")) + } + + @Test + fun aShortcutPinnedBeforeTheIdsWereWidenedStillOpensItsStop() { + // A pinned shortcut keeps the intent it was created with for as long as + // it sits on the home screen, so the extras older builds wrote — these + // names, holding Ints — are a format that has to stay readable. + val pinnedByAnOlderBuild = Intent() + .putExtra("launch_target", "route_detail") + .putExtra("provider", "txg") + .putExtra("route_key", 1) + .putExtra("path_id", 0) + .putExtra("stop_id", 13567) + .putExtra("destination_path_id", 0) + .putExtra("destination_stop_id", 13570) + + val payload = launchPayloadOf(pinnedByAnOlderBuild) + + assertEquals(13567L, payload.stopId("stopId")) + assertEquals(13570L, payload.stopId("destinationStopId")) + } + + @Test + fun tappingAWidgetRowLaunchesWithTheFullWidthId() { + // The config is what Dart saves: the ids as Dart holds them. + val config = JSONObject().put( + "通勤", + JSONArray().put( + JSONObject() + .put("type", "boarding") + .put("provider", "khh") + .put("routeKey", 100) + .put("pathId", 0) + .put("stopId", RAW_STOP_ID) + .put("routeId", "KHH100") + .put("stopName", "測試站") + .put("destinationPathId", 0) + .put("destinationStopId", RAW_STOP_ID + 30), + ), + ).toString() + val item = FavoriteGroupWidgetSupport.parseFavoriteGroups(config).getValue("通勤").single() + + assertEquals( + "the live feed is still looked up by the folded key", + foldStopId(RAW_STOP_ID), + item.stopId, + ) + + val tap = FavoriteGroupWidgetSupport.createItemPendingIntent(application, item) + val payload = launchPayloadOf(shadowOf(tap).savedIntent) + + assertEquals(RAW_STOP_ID, payload.stopId("stopId")) + assertEquals(RAW_STOP_ID + 30, payload.stopId("destinationStopId")) + } + + @Test + fun tappingASmartRouteNotificationLaunchesWithTheFullWidthId() { + // The stop comes out of the route database Flutter syncs, where the id + // is stored as text. + val databaseFile = File( + application.filesDir.parentFile, + "app_flutter/.yabus_backend/bus_khh_v2.sqlite", + ) + databaseFile.parentFile!!.mkdirs() + SQLiteDatabase.openOrCreateDatabase(databaseFile, null).use { database -> + database.execSQL("CREATE TABLE routes (route_key INTEGER, route_id TEXT, route_name TEXT)") + database.execSQL("CREATE TABLE paths (route_key INTEGER, path_id INTEGER, path_name TEXT)") + database.execSQL( + "CREATE TABLE stops (route_key INTEGER, path_id INTEGER, stop_id TEXT, " + + "stop_name TEXT, sequence INTEGER, lon REAL, lat REAL)", + ) + database.execSQL("INSERT INTO routes VALUES (100, 'KHH100', '100')") + database.execSQL("INSERT INTO paths VALUES (100, 0, '去程')") + database.execSQL( + "INSERT INTO stops VALUES (100, 0, '$RAW_STOP_ID', '測試站', 1, 120.338401, 22.60612)", + ) + } + val routeData = requireNotNull( + SmartRouteNotificationSupport.loadRouteData(application, "khh", 100, "100"), + ) + val here = Location("test").apply { + latitude = 22.60612 + longitude = 120.338401 + } + val nearestStop = requireNotNull( + SmartRouteNotificationSupport.findNearestStop(here, routeData, pathId = 0), + ) + + assertEquals( + "the live feed is still looked up by the folded key", + foldStopId(RAW_STOP_ID), + nearestStop.stopId, + ) + + SmartRouteNotificationSupport.ensureChannel(application) + SmartRouteNotificationSupport.showNotification( + context = application, + profile = SmartRouteProfile( + provider = "khh", + routeKey = 100, + pathId = 0, + routeName = "100", + totalOpens = 5, + lastOpenedAtMs = 0L, + totalSelections = 0, + lastSelectedAtMs = 0L, + hourlyOpens = mapOf(8 to 5), + hourlySelections = emptyMap(), + ), + routeData = routeData, + nearestStop = nearestStop, + liveStop = SmartLiveStop(sec = 120, msg = null), + distanceMeters = nearestStop.distanceMeters, + nowMs = 1L, + ) + val posted = shadowOf(application.getSystemService(NotificationManager::class.java)) + .allNotifications + .single() + val payload = launchPayloadOf(shadowOf(posted.contentIntent).savedIntent) + + assertEquals(RAW_STOP_ID, payload.stopId("stopId")) + } + + private fun launchPayloadOf(intent: Intent): Map { + val payload = AppLaunchConstants.extractLaunchPayload(intent) + assertTrue("expected a launch payload", payload != null) + return payload!! + } + + private fun Map.stopId(key: String): Long? = (this[key] as Number?)?.toLong() + + private companion object { + /** + * A real 高雄 stop id. Its low 32 bits are negative, so any path that + * narrows it to an `Int` — by folding or by truncating — changes it. + */ + const val RAW_STOP_ID = 101111144601L + } +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt new file mode 100644 index 0000000..e92fc85 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RideFixTimeTest.kt @@ -0,0 +1,68 @@ +package tw.avianjay.taiwanbus.flutter + +import android.app.Application +import android.location.Location +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A fix handed to the ride detector has to carry when it was really measured. + * + * It used to be aged by subtracting `Location.time` (Unix epoch) from + * `SystemClock.elapsedRealtime()` (time since boot). That difference is hugely + * negative and was clamped to zero, so every fix — including a position cached + * minutes earlier — reached the detector stamped "now". + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class RideFixTimeTest { + private val nowWallMs = 1_700_000_000_000L + private val nowElapsedNanos = TimeUnit.HOURS.toNanos(3) + + @Test + fun aCachedFixIsPlacedItsRealAgeInThePast() { + val cached = Location("fused").apply { + elapsedRealtimeNanos = nowElapsedNanos - TimeUnit.MINUTES.toNanos(5) + // A provider's clock need not agree with the device's: the age has + // to come from the monotonic stamp, not from this. + time = nowWallMs + TimeUnit.DAYS.toMillis(1) + } + + assertEquals(nowWallMs - 300_000L, rideFixTimestampMs(cached, nowWallMs, nowElapsedNanos)) + } + + @Test + fun aFixStampedAheadOfTheClockCountsAsCurrent() { + val ahead = Location("fused").apply { + elapsedRealtimeNanos = nowElapsedNanos + TimeUnit.SECONDS.toNanos(2) + } + + assertEquals(nowWallMs, rideFixTimestampMs(ahead, nowWallMs, nowElapsedNanos)) + } + + @Test + fun aHandBuiltPositionIsAgedByItsOwnTime() { + // The session's starting position is built from what Flutter sent, and + // has no monotonic stamp; Flutter says when it was measured instead. + val seed = Location("trip_monitor_session").apply { + time = nowWallMs - 180_000L + } + + assertEquals(nowWallMs - 180_000L, rideFixTimestampMs(seed, nowWallMs, nowElapsedNanos)) + } + + @Test + fun aHandBuiltPositionWithNoUsableTimeCountsAsCurrent() { + val untimed = Location("trip_monitor_session") + val fromTheFuture = Location("trip_monitor_session").apply { + time = nowWallMs + 60_000L + } + + assertEquals(nowWallMs, rideFixTimestampMs(untimed, nowWallMs, nowElapsedNanos)) + assertEquals(nowWallMs, rideFixTimestampMs(fromTheFuture, nowWallMs, nowElapsedNanos)) + } +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index 3518007..bfb90b8 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -3,9 +3,16 @@ package tw.avianjay.taiwanbus.flutter import android.app.Application import android.app.Notification import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service +import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.location.Location +import android.os.Looper +import android.os.SystemClock +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowSystemClock import java.util.concurrent.ExecutorService import java.util.concurrent.TimeUnit import org.junit.After @@ -345,6 +352,203 @@ class RouteTripMonitorServiceTest { assertEquals(120, liveStops[boardingStopId]!!.seconds) } + @Test + fun aCachedFixFromMinutesAgoIsNotTreatedAsWhereTheRiderIs() { + // Robolectric's clock starts at boot; move on so "five minutes ago" exists. + ShadowSystemClock.advanceBy(10, TimeUnit.MINUTES) + service.onStartCommand(startIntent(threeStopSession()), 0, 1) + + // What the fused provider hands back first: its cached last location. + feedFix(stopIndex = 2, ageMinutes = 5) + + assertFalse("a five-minute-old fix is not a usable position", ride(service).hasUsableLocation) + assertEquals( + "and must not decide where the trip starts", + -1, + ride(service).boardingStopIndex, + ) + + feedFix(stopIndex = 1, ageMinutes = 0) + + assertTrue(ride(service).hasUsableLocation) + assertEquals("the trip starts where the rider is now", 1, ride(service).boardingStopIndex) + } + + @Test + fun theSessionsStartingPositionIsAgedByWhenFlutterMeasuredIt() { + ShadowSystemClock.advanceBy(10, TimeUnit.MINUTES) + val (lat, lon) = THREE_STOPS[2] + service.onStartCommand( + startIntent( + threeStopSession() + mapOf( + "initialLatitude" to lat, + "initialLongitude" to lon, + "initialLocationTimestampMs" to System.currentTimeMillis() - 5 * 60_000L, + ), + ), + 0, + 1, + ) + refreshRide() + + assertFalse(ride(service).hasUsableLocation) + assertEquals(-1, ride(service).boardingStopIndex) + } + + @Test + fun boardingWithoutADestinationAsksForOneAndPicksTheRideBackUp() { + val withBoardingStop = threeStopSession() + mapOf( + "boardingStopId" to 1, + "boardingStopName" to "一站", + ) + // With and without a boarding stop chosen: both used to be reported as + // "still waiting" however the ride had been confirmed. + for (waiting in listOf(withBoardingStop, threeStopSession())) { + service.onStartCommand(startIntent(waiting), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + assertTrue(ride(service).hasBoarded) + + val paused = awaitPause() + + assertEquals("boarded_no_destination", paused.reason) + val prompt = shadowOf(service.getSystemService(NotificationManager::class.java)) + .getNotification(6022) + assertNotNull("the rider must be asked where they get off", prompt) + assertEquals("已偵測上車", prompt.extras.getString(Notification.EXTRA_TITLE)) + + // The rider answers by choosing a stop. + service.onStartCommand( + startIntent(waiting + mapOf("destinationStopId" to 3, "destinationStopName" to "三站")), + 0, + 3, + ) + + assertTrue("choosing a destination continues the same ride", ride(service).hasBoarded) + assertEquals(BoardingEvidence.USER_CONFIRMED, ride(service).evidence) + assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 4) + } + } + + @Test + fun onlyTheRidePausedForADestinationIsPickedBackUp() { + val waiting = threeStopSession() + mapOf("boardingStopId" to 1) + val withDestination = waiting + mapOf("destinationStopId" to 3) + fun startedAsBoarded( + pausedAs: Map, + reason: String, + resumedAs: Map, + pausedMinutesAgo: Long = 1, + ): Boolean { + AppRuntimeStateStore.savePausedTripMonitor( + service, + RouteTripMonitorService.parseSessionPayload(pausedAs)!!, + reason, + ) + // The store stamps the pause with the real clock, so it is aged here. + service.getSharedPreferences("app_runtime_state", Context.MODE_PRIVATE).edit() + .putLong( + "trip_monitor_paused_at_ms", + System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(pausedMinutesAgo), + ) + .commit() + service.onStartCommand(startIntent(resumedAs), 0, 1) + val boarded = ride(service).hasBoarded + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 2) + return boarded + } + + assertTrue(startedAsBoarded(waiting, "boarded_no_destination", withDestination)) + assertFalse( + "a pause the rider asked for says nothing about being on board", + startedAsBoarded(waiting, "user", withDestination), + ) + assertFalse( + "another boarding stop is another trip", + startedAsBoarded(waiting, "boarded_no_destination", withDestination + mapOf("boardingStopId" to 2)), + ) + assertFalse( + "an hour on, that ride is over", + startedAsBoarded(waiting, "boarded_no_destination", withDestination, pausedMinutesAgo = 61), + ) + } + + @Test + fun tappingATripNotificationLaunchesWithTheFullWidthStopId() { + val rawStopId = 101111144601L + val session = RouteTripMonitorService.parseSessionPayload( + mapOf( + "provider" to "khh", + "routeKey" to 100, + "pathId" to 0, + "destinationStopId" to rawStopId, + "stops" to listOf( + mapOf("stopId" to rawStopId, "stopName" to "測試站", "lat" to 22.6, "lon" to 120.3), + ), + ), + )!! + assertEquals("the feed join still uses the folded key", foldStopId(rawStopId), session.destinationStopId) + + val tap = ReflectionHelpers.callInstanceMethod( + service, + "createOpenRoutePendingIntent", + ReflectionHelpers.ClassParameter.from(TrackingSession::class.java, session), + ) + val payload = AppLaunchConstants.extractLaunchPayload(shadowOf(tap).savedIntent)!! + + assertEquals(rawStopId, (payload["stopId"] as Number).toLong()) + } + + private fun threeStopSession(): Map = sessionPayload() + mapOf( + "stops" to THREE_STOPS.mapIndexed { index, (lat, lon) -> + mapOf( + "stopId" to index + 1, + "stopName" to "${"一二三"[index]}站", + "sequence" to index + 1, + "lat" to lat, + "lon" to lon, + ) + }, + ) + + /** Delivers a fix at a stop, as the location callback does, and re-reads the ride. */ + private fun feedFix(stopIndex: Int, ageMinutes: Long) { + val (lat, lon) = THREE_STOPS[stopIndex] + val fix = Location("fused").apply { + latitude = lat + longitude = lon + accuracy = 10f + elapsedRealtimeNanos = + SystemClock.elapsedRealtimeNanos() - TimeUnit.MINUTES.toNanos(ageMinutes) + time = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(ageMinutes) + } + ReflectionHelpers.callInstanceMethod( + service, + "onUserLocation", + ReflectionHelpers.ClassParameter.from(Location::class.java, fix), + ) + refreshRide() + } + + private fun refreshRide() { + ReflectionHelpers.callInstanceMethod( + service, + "refreshRideSnapshot", + ReflectionHelpers.ClassParameter.from(Long::class.javaPrimitiveType, System.currentTimeMillis()), + ) + } + + /** Lets the service's own refresh loop run until it pauses the trip. */ + private fun awaitPause(): TripMonitorPauseState { + repeat(100) { + AppRuntimeStateStore.loadPausedTripMonitor(service)?.let { return it } + shadowOf(Looper.getMainLooper()).idleFor(500, TimeUnit.MILLISECONDS) + Thread.sleep(50) + } + throw AssertionError("the monitor never paused to ask for a destination") + } + private fun ride(service: RouteTripMonitorService): RideSnapshot = ReflectionHelpers.getField(service, "latestRide") @@ -373,6 +577,9 @@ class RouteTripMonitorServiceTest { ) } +/** Three stops about 500 m apart, running north. */ +private val THREE_STOPS = listOf(24.1000 to 120.6, 24.1045 to 120.6, 24.1090 to 120.6) + @Implements(Service::class) class RecordingTripServiceShadow : ShadowService() { @RealObject diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorDeclineTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorDeclineTest.kt new file mode 100644 index 0000000..b941b76 --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorDeclineTest.kt @@ -0,0 +1,404 @@ +package tw.avianjay.taiwanbus.flutter + +import kotlin.math.PI +import kotlin.math.cos +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "還沒上車" is an answer about one bus, not about the trip. + * + * It used to switch automatic detection off until the trip was rebuilt, so the + * bus the rider was actually waiting for — usually the next one along, minutes + * behind the one they turned down — was never detected, no boarding alert + * fired, and the destination reminders never armed. + * + * The route is a straight northbound line of five stops 500 m apart, and the + * rider waits at the first one, wandering off it: a phone that never moves is + * read as "still waiting" and is deliberately never asked anything. + */ +class TripRideDetectorDeclineTest { + @Test + fun decliningOneBusStillDetectsTheNext() { + val detector = newDetector() + var declinedAtMs = 0L + var promptsAfterTheAnswer = 0 + var boardedAtMs = 0L + var boardedVehicleId: String? = null + + detector.feed(firstWaitThenRide(), listOf(declinedBusRun(), nextBusRun()), 0, 560) { nowMs, snapshot -> + if (declinedAtMs == 0L) { + if (snapshot.boardingPromptDue) { + // The question the rider answers with "還沒上車", asked about + // the first bus to leave the stop. + detector.declineBoarding(nowMs) + declinedAtMs = nowMs + } + } else if (boardedAtMs == 0L && snapshot.boardingPromptDue) { + promptsAfterTheAnswer++ + } + if (boardedAtMs == 0L && snapshot.hasBoarded) { + boardedAtMs = nowMs + boardedVehicleId = snapshot.trackedVehicleId + } + } + + assertTrue("expected the rider to be asked about the first bus", declinedAtMs > 0L) + assertEquals( + "the bus that was turned down must not be asked about again", + 0, + promptsAfterTheAnswer, + ) + assertTrue("boarding the next bus must still be detected", boardedAtMs > 0L) + assertEquals("and the ride must latch onto the bus actually ridden", NEXT_BUS, boardedVehicleId) + } + + @Test + fun theBoardingQuestionComesBackForTheNextBus() { + val detector = newDetector() + var declinedAtMs = 0L + var askedAboutTheNextBus = false + var boarded = false + + detector.feed(firstWaitThenWatch(), listOf(declinedBusRun(), nextBusRun()), 0, 520) { nowMs, snapshot -> + if (declinedAtMs == 0L) { + if (snapshot.boardingPromptDue) { + detector.declineBoarding(nowMs) + declinedAtMs = nowMs + } + } else if (snapshot.boardingPromptDue) { + askedAboutTheNextBus = true + } + if (snapshot.hasBoarded) { + boarded = true + } + } + + assertTrue("expected the rider to be asked about the first bus", declinedAtMs > 0L) + assertFalse("the rider never got on", boarded) + assertTrue( + "turning down one bus must not silence the question for the next one", + askedAboutTheNextBus, + ) + } + + @Test + fun aTurnedDownVehicleCanBeDetectedAgainOnceItsRejectionLapses() { + val detector = newDetector() + var declinedAtMs = 0L + var boardedAtMs = 0L + var boardedVehicleId: String? = null + + // The same vehicle on its next run, which begins while it is still + // turned down. Only the rejection lapsing lets the ride be detected, so + // the feed stops the moment it should be — a longer feed would let the + // rider's own progress detect the ride instead and hide the bug. + detector.feed(waitingForItsNextRun(), listOf(declinedBusRun(), returnedRun()), 0, 1440) { nowMs, snapshot -> + if (declinedAtMs == 0L) { + if (snapshot.boardingPromptDue) { + detector.declineBoarding(nowMs) + declinedAtMs = nowMs + } + } + if (boardedAtMs == 0L && snapshot.hasBoarded) { + boardedAtMs = nowMs + boardedVehicleId = snapshot.trackedVehicleId + } + } + + assertTrue("expected the rider to be asked about the first bus", declinedAtMs > 0L) + assertTrue( + "the same vehicle must be detectable once its rejection has lapsed", + boardedAtMs > 0L, + ) + assertEquals(DECLINED_BUS, boardedVehicleId) + assertTrue( + "detected ${(boardedAtMs - declinedAtMs) / 1000}s after the answer, which is inside " + + "the 1200s rejection — this run only proves the lapse if the ride is detected " + + "after it", + boardedAtMs - declinedAtMs > 1_200_000L, + ) + } + + @Test + fun confirmingByHandOutranksATurnedDownVehicle() { + val detector = newDetector() + var declinedAtMs = 0L + var confirmed = false + var boarded = false + + detector.feed(firstWaitThenWatch(), listOf(declinedBusRun(), nextBusRun()), 0, 400) { nowMs, snapshot -> + if (declinedAtMs == 0L) { + if (snapshot.boardingPromptDue) { + detector.declineBoarding(nowMs) + declinedAtMs = nowMs + } + } else if (!confirmed && nowMs >= at(300)) { + // The rider stops waiting on detection and taps the manual + // action, exactly as the notification does. + detector.confirmBoardedByUser(nowMs) + confirmed = true + } + if (snapshot.hasBoarded) { + boarded = true + } + } + + assertTrue("expected the rider to be asked about the first bus", declinedAtMs > 0L) + assertTrue(confirmed) + assertTrue("the manual action has to keep working after a decline", boarded) + } + + @Test + fun theSameDepartureEchoedByTheEtaFeedIsNotAskedAboutTwice() { + // No location at all, so asking is the only tool and nothing else can + // quiet the question. + val detector = newDetector() + detector.leaveTheStop(DECLINED_BUS, fromSeconds = 0) + detector.onBoardingStopEta(etaSeconds = 0, arriving = true, nowMs = at(20)) + assertTrue(detector.snapshot(at(80)).boardingPromptDue) + detector.declineBoarding(at(80)) + + // The ETA rolling over to the next bus is the same departure, late. + detector.onBoardingStopEta(etaSeconds = 600, arriving = false, nowMs = at(100)) + + assertFalse(detector.snapshot(at(140)).boardingPromptDue) + } + + @Test + fun aLateAnswerDoesNotSwallowTheNextDeparture() { + val detector = newDetector() + detector.leaveTheStop(DECLINED_BUS, fromSeconds = 0) + // The rider gets round to the question four minutes later. + assertTrue(detector.snapshot(at(280)).boardingPromptDue) + detector.declineBoarding(at(280)) + + // Moments after the answer the next bus pulls in and leaves; it has no + // position feed, so the ETA is all that reports it. + detector.onBoardingStopEta(etaSeconds = 0, arriving = true, nowMs = at(290)) + detector.onBoardingStopEta(etaSeconds = 600, arriving = false, nowMs = at(310)) + + assertTrue( + "the next bus must be asked about however late the last answer came", + detector.snapshot(at(350)).boardingPromptDue, + ) + } + + /** A bus pulls in at the boarding stop and drives off, 20 s a report. */ + private fun TripRideDetector.leaveTheStop(vehicleId: String, fromSeconds: Long) { + val run = busRun( + vehicleId, + fromSeconds to 10.0, + fromSeconds + 20 to 30.0, + fromSeconds + 40 to 150.0, + ) + for (seconds in fromSeconds..fromSeconds + 40 step 20) { + onBusReports(run.reportsAt(at(seconds))) + snapshot(at(seconds)) + } + } + + private fun newDetector() = TripRideDetector( + stops = routeStops(), + pathId = 0, + boardingStopId = BOARDING_STOP_ID, + destinationStopId = BOARDING_STOP_ID + 4, + ) +} + +private const val BOARDING_STOP_ID = 900 + +/** An arbitrary wall-clock instant, aligned to the 20 s poll grid below. */ +private const val BASE_MS = 1_700_000_000_000L + +private const val DECLINED_BUS = "AAA-001" +private const val NEXT_BUS = "BBB-002" + +private fun at(seconds: Long): Long = BASE_MS + seconds * 1_000L + +private val METERS_PER_DEGREE_LAT = 6_371_000.0 * PI / 180.0 +private val METERS_PER_DEGREE_LON = METERS_PER_DEGREE_LAT * cos(24.0 * PI / 180.0) + +/** [offsetMeters] is measured east of the route, which runs due north. */ +private fun point(alongMeters: Double, offsetMeters: Double): Pair = + 24.0 + alongMeters / METERS_PER_DEGREE_LAT to 120.5 + offsetMeters / METERS_PER_DEGREE_LON + +private fun routeStops(): List = (0..4).map { index -> + val (lat, lon) = point(index * 500.0, 0.0) + TrackingStop( + stopId = BOARDING_STOP_ID + index, + stopName = "stop-$index", + sequence = index, + lat = lat, + lon = lon, + ) +} + +/** The rider's path as waypoints, linearly interpolated by the frame clock. */ +private class UserPath(private val waypoints: List) { + data class Waypoint( + val tMs: Long, + val alongMeters: Double, + val offsetMeters: Double, + val speedMetersPerSecond: Float, + ) + + fun fix(tMs: Long): RideGeoPoint { + val index = waypoints.indexOfLast { it.tMs <= tMs }.coerceAtLeast(0) + val from = waypoints[index] + val to = waypoints[(index + 1).coerceAtMost(waypoints.lastIndex)] + val span = (to.tMs - from.tMs).toDouble() + val fraction = if (span <= 0.0) 0.0 else ((tMs - from.tMs) / span).coerceIn(0.0, 1.0) + val (lat, lon) = point( + from.alongMeters + fraction * (to.alongMeters - from.alongMeters), + from.offsetMeters + fraction * (to.offsetMeters - from.offsetMeters), + ) + return RideGeoPoint( + latitude = lat, + longitude = lon, + timestampMs = tMs, + accuracyMeters = 12f, + speedMetersPerSecond = from.speedMetersPerSecond, + ) + } +} + +private fun path(vararg waypoints: UserPath.Waypoint) = UserPath(waypoints.toList()) + +private fun step(seconds: Long, along: Double, offset: Double, speed: Float) = + UserPath.Waypoint(at(seconds), along, offset, speed) + +/** One bus's positions, keyed by the poll they were first seen in. */ +private class BusRun(val vehicleId: String, positions: List>) { + private val alongByTime = positions.toMap() + + fun reportsAt(tMs: Long): List { + val along = alongByTime[tMs] ?: return emptyList() + val (lat, lon) = point(along, 0.0) + return listOf( + RideBusReport( + vehicleId = vehicleId, + pathId = 0, + latitude = lat, + longitude = lon, + gpsTimeMs = tMs, + receivedAtMs = tMs, + ), + ) + } +} + +private fun busRun(vehicleId: String, vararg positions: Pair) = + BusRun(vehicleId, positions.map { at(it.first) to it.second }) + +/** + * Pulls in at the boarding stop, is turned down, and drives away. It keeps + * reporting while it leaves, which is what the rejection is scoped to. + */ +private fun declinedBusRun() = busRun( + DECLINED_BUS, + 40L to 10.0, + 60L to 30.0, + 80L to 100.0, + 100L to 150.0, + 120L to 250.0, + 140L to 350.0, +) + +/** The bus the rider actually takes, five minutes behind the one they refused. */ +private fun nextBusRun() = busRun( + NEXT_BUS, + 340L to 10.0, + 360L to 25.0, + 380L to 35.0, + 400L to 45.0, + 420L to 130.0, + 440L to 230.0, + 460L to 330.0, + 480L to 430.0, + 500L to 530.0, + 520L to 630.0, + 540L to 730.0, + 560L to 830.0, +) + +/** + * The same vehicle on its next run, which starts 19 minutes after it was + * refused — before the rejection lapses — and leaves the stop after it has. + */ +private fun returnedRun() = busRun( + DECLINED_BUS, + 1300L to 10.0, + 1320L to 15.0, + 1340L to 20.0, + 1360L to 25.0, + 1380L to 30.0, + 1400L to 130.0, + 1420L to 230.0, + 1440L to 330.0, +) + +/** + * Wanders 250 m off the stop — enough that the app cannot call it "still + * waiting" — comes back, and rides the next bus at 5 m/s. + */ +private fun firstWaitThenRide() = path( + step(0, along = 0.0, offset = 0.0, speed = 0.3f), + step(80, along = 0.0, offset = 0.0, speed = 2.5f), + step(180, along = 0.0, offset = 250.0, speed = 2.5f), + step(200, along = 0.0, offset = 240.0, speed = 1.15f), + step(400, along = 0.0, offset = 0.0, speed = 1.15f), + step(420, along = 100.0, offset = 0.0, speed = 5f), + step(560, along = 800.0, offset = 0.0, speed = 5f), +) + +/** The same wait, but the rider wanders away instead of boarding. */ +private fun firstWaitThenWatch() = path( + step(0, along = 0.0, offset = 0.0, speed = 0.3f), + step(80, along = 0.0, offset = 0.0, speed = 2.5f), + step(180, along = 0.0, offset = 250.0, speed = 2.5f), + step(200, along = 0.0, offset = 240.0, speed = 1.15f), + step(400, along = 0.0, offset = 0.0, speed = 1.15f), + step(420, along = 0.0, offset = 0.0, speed = 2.5f), + step(520, along = 0.0, offset = 250.0, speed = 2.5f), +) + +/** + * Waits at the stop for the same vehicle's next run and boards it as it leaves. + * The ride is short on purpose — under 500 m of travel, so that co-movement is + * the only layer that can detect it and the vehicle's rejection is the one + * thing standing in the way. + */ +private fun waitingForItsNextRun() = path( + step(0, along = 0.0, offset = 0.0, speed = 0.3f), + step(80, along = 0.0, offset = 0.0, speed = 2.5f), + step(180, along = 0.0, offset = 250.0, speed = 2.5f), + step(200, along = 0.0, offset = 240.0, speed = 1.15f), + step(400, along = 0.0, offset = 0.0, speed = 1.15f), + step(1380, along = 0.0, offset = 0.0, speed = 0.3f), + step(1400, along = 100.0, offset = 0.0, speed = 5f), + step(1440, along = 300.0, offset = 0.0, speed = 5f), +) + +/** + * Feeds the polls in time order, as the service does: the frame's bus reports + * first, then the rider's fix, then the snapshot the app would take. + */ +private fun TripRideDetector.feed( + userPath: UserPath, + buses: List, + fromSeconds: Long, + toSeconds: Long, + onFrame: (Long, RideSnapshot) -> Unit, +) { + var seconds = fromSeconds + while (seconds <= toSeconds) { + val nowMs = at(seconds) + buses.forEach { onBusReports(it.reportsAt(nowMs)) } + onUserLocation(userPath.fix(nowMs)) + onFrame(nowMs, snapshot(nowMs)) + seconds += 20 + } +} diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt index 68ffa23..3d4cd14 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorReplayTest.kt @@ -165,21 +165,29 @@ class TripRideDetectorReplayTest { } @Test - fun decliningABoardingKeepsTheRideCancelled() { + fun aDeclineIsNotOverruledByTheEvidenceItAnswered() { val replay = Replay.load("ride_replay_TXG300_0") val detector = replay.detector() var declined = false + val looksAfterTheAnswer = ArrayList() replay.pump(detector, withUserFixes = true) { nowMs, snapshot -> if (!declined && snapshot.hasBoarded) { - detector.declineBoarding() + // Everything that detected this ride is still in the detector's + // window when the rider says no. + detector.declineBoarding(nowMs) declined = true + looksAfterTheAnswer += detector.snapshot(nowMs) + } else if (declined && looksAfterTheAnswer.size < 2) { + looksAfterTheAnswer += snapshot } } - assertTrue("expected a ride to be detected before declining it", declined) - val snapshot = detector.snapshot(replay.lastFrameMs) - assertFalse("declining must stick", snapshot.hasBoarded) - assertEquals(BoardingEvidence.NONE, snapshot.evidence) + assertTrue("expected the recorded ride to be detected", declined) + assertEquals(2, looksAfterTheAnswer.size) + looksAfterTheAnswer.forEach { snapshot -> + assertFalse("the answer holds at the next look", snapshot.hasBoarded) + assertEquals(BoardingEvidence.NONE, snapshot.evidence) + } } @Test diff --git a/lib/core/android_trip_monitor.dart b/lib/core/android_trip_monitor.dart index 0c97a72..0f83066 100644 --- a/lib/core/android_trip_monitor.dart +++ b/lib/core/android_trip_monitor.dart @@ -105,6 +105,7 @@ class TripMonitorSession { required this.stops, this.initialLatitude, this.initialLongitude, + this.initialLocationTimestampMs, this.boardingStopId, this.boardingStopName, this.destinationStopId, @@ -123,6 +124,11 @@ class TripMonitorSession { final List stops; final double? initialLatitude; final double? initialLongitude; + + /// When the initial position was measured, in epoch milliseconds. The route + /// screen's last fix can be minutes old by the time a trip starts, and the + /// monitor must not mistake where the rider was for where they are. + final int? initialLocationTimestampMs; final int? boardingStopId; final String? boardingStopName; final int? destinationStopId; @@ -141,6 +147,7 @@ class TripMonitorSession { 'backgroundLocationAlwaysGranted': backgroundLocationAlwaysGranted, 'initialLatitude': initialLatitude, 'initialLongitude': initialLongitude, + 'initialLocationTimestampMs': initialLocationTimestampMs, 'boardingStopId': boardingStopId, 'boardingStopName': boardingStopName, 'destinationStopId': destinationStopId, diff --git a/lib/screens/route_detail_screen.dart b/lib/screens/route_detail_screen.dart index ea9fa24..0496c19 100644 --- a/lib/screens/route_detail_screen.dart +++ b/lib/screens/route_detail_screen.dart @@ -2130,6 +2130,8 @@ class _RouteDetailScreenState extends State destinationStopName: _destinationStopName, initialLatitude: _lastPosition?.latitude, initialLongitude: _lastPosition?.longitude, + initialLocationTimestampMs: + _lastPosition?.timestamp.millisecondsSinceEpoch, stops: resolvedStops.map(TripMonitorStop.fromStop).toList(), ); } @@ -2730,6 +2732,16 @@ class _RouteDetailScreenState extends State _destinationStopId = stop.stopId; _destinationStopName = stop.stopName; }); + if (_isAndroid && _backgroundTripMonitorPaused) { + // The service pauses a trip, not the screen: with a destination this is + // a different trip to it, and holding on to the old answer would leave + // the monitor off. That stranded the "you have boarded, choose where to + // get off" prompt — choosing never started anything again. + await _syncBackgroundTripMonitorPausedState(); + if (!mounted) { + return; + } + } if (boardingStop != null && pathId != null) { final controller = AppControllerScope.read(context); await controller.recordDestinationChoice( diff --git a/test/android_trip_monitor_test.dart b/test/android_trip_monitor_test.dart index f0dc751..7a74af6 100644 --- a/test/android_trip_monitor_test.dart +++ b/test/android_trip_monitor_test.dart @@ -54,7 +54,69 @@ void main() { final saved = (map['stops'] as List).single as Map; expect(saved['rawStopId'], 'MAIN-STOP'); expect((saved['liveData'] as Map)['eta'], 60); - expect((saved['liveData'] as Map)['buses'], [{'id': 'FAMILY-001'}]); + expect((saved['liveData'] as Map)['buses'], [ + {'id': 'FAMILY-001'}, + ]); + }); + + test('session carries when its initial position was measured', () { + // The route screen's last fix can be minutes old when a trip starts. The + // monitor judges staleness from this, so it has to cross the channel. + final measuredAt = DateTime.utc(2026, 11, 12, 8, 30); + final map = TripMonitorSession( + providerName: 'txg', + routeKey: 1, + routeName: '1', + pathId: 0, + pathName: '去程', + appInForeground: true, + backgroundLocationAlwaysGranted: false, + stops: const [], + initialLatitude: 24.1, + initialLongitude: 120.6, + initialLocationTimestampMs: measuredAt.millisecondsSinceEpoch, + ).toMap(); + + expect( + map['initialLocationTimestampMs'], + measuredAt.millisecondsSinceEpoch, + ); + }); + + test('session keeps 64-bit stop ids at full width', () { + // 高雄 stop ids exceed 32 bits. The native side folds them for its own + // feed joins, but what it is handed has to be the id Dart itself holds — + // that is what a notification tap is launched back with. + const stopId = 101111144601; + final map = TripMonitorSession( + providerName: 'khh', + routeKey: 100, + routeId: 'KHH100', + routeName: '100', + pathId: 0, + pathName: '去程', + appInForeground: true, + backgroundLocationAlwaysGranted: false, + boardingStopId: stopId, + destinationStopId: stopId + 30, + stops: [ + TripMonitorStop.fromStop( + const StopInfo( + routeKey: 100, + pathId: 0, + stopId: stopId, + stopName: '測試站', + sequence: 1, + lat: 22.6, + lon: 120.3, + ), + ), + ], + ).toMap(); + + expect(map['boardingStopId'], stopId); + expect(map['destinationStopId'], stopId + 30); + expect(((map['stops'] as List).single as Map)['stopId'], stopId); }); test('lifecycle calls tolerate a detached platform channel', () async { diff --git a/test/app_routes_test.dart b/test/app_routes_test.dart index 31c95f0..7fc1514 100644 --- a/test/app_routes_test.dart +++ b/test/app_routes_test.dart @@ -143,4 +143,26 @@ void main() { expect(action.routeKey, isNull); expect(action.stopId, isNull); }); + + test('native route launch payload keeps 64-bit stop ids', () { + // 高雄 stop ids exceed 32 bits, and the platform channel hands those over + // as a 64-bit integer. A widget or notification tap has to arrive naming + // the same stop the route screen's own models hold, or nothing is selected. + const stopId = 101111144601; + final action = AppLaunchAction.fromMap({ + 'target': 'route_detail', + 'provider': 'khh', + 'routeKey': 100, + 'routeId': 'KHH100', + 'pathId': 0, + 'stopId': stopId, + 'destinationPathId': 0, + 'destinationStopId': stopId + 30, + }); + + expect(action.target, AppLaunchTarget.routeDetail); + expect(action.provider, BusProvider.khh); + expect(action.stopId, stopId); + expect(action.destinationStopId, stopId + 30); + }); } diff --git a/test/route_detail_screen_loading_test.dart b/test/route_detail_screen_loading_test.dart index c988904..4dc2f00 100644 --- a/test/route_detail_screen_loading_test.dart +++ b/test/route_detail_screen_loading_test.dart @@ -830,6 +830,62 @@ void main() { expect(live['buses'], isEmpty); }); + _screenTest('choosing a destination restarts a monitor paused without one', ( + tester, + repository, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + GeolocatorPlatform.instance = _BackgroundLocation(); + const channel = MethodChannel('tw.avianjay.taiwanbus.flutter/trip_monitor'); + final sessions = >[]; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + final session = (call.arguments as Map?)?['session'] as Map?; + if (call.method == 'startOrUpdateTripMonitor') sessions.add(session!); + // The service paused the trip that had no destination, as it does once + // it has detected boarding and asked where the rider gets off. + if (call.method == 'isTripMonitorPaused') { + return session!['destinationStopId'] == null; + } + return null; + }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + }); + final controller = AppControllerScope.read( + tester.element(find.byType(RouteDetailScreen)), + ); + await controller.updateEnableRouteBackgroundMonitor(true); + repository.topologyRequests.single.complete(_detail()); + repository.primaryRequests.single.complete(_detail(eta: 300)); + await _frames(tester, 8); + repository.familyRequests.single.completer.complete( + repository.familyRequests.single.selected, + ); + await _frames(tester); + expect(sessions, isNotEmpty); + final sentWhilePaused = sessions.length; + + await tester.tap(find.byTooltip('背景乘車提醒')); + await _frames(tester); + await tester.tap(find.text('設定下車提醒')); + await _frames(tester); + await tester.tap( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text('返程站5'), + ), + ); + await _frames(tester, 8); + + expect(sessions.length, greaterThan(sentWhilePaused)); + expect(sessions.last['destinationStopId'], 5); + }); + _screenTest('a new refresh rejects late family results', ( tester, repository, From 2c66facb1120916b4647323c93d3285344817778 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Fri, 13 Nov 2026 17:15:57 +0800 Subject: [PATCH 03/11] fix(android): keep the trip monitor's feeds and direction honest Three further review findings, each with a service-level test. - A plate on an ETA entry is a bus still on its way, not one at the stop. Counting it as "arriving" hid the ETA rolling over to the next bus, which is the only departure signal when the bus that left has no position feed. Only buses the feed places at the stop count now. - The stop feed and the vehicle feed back off separately, so a failing stop feed no longer stops vehicle positions from being fetched. - Direction and stop order are part of what makes a trip. Switching to the other direction with the same boarding stop rebuilds the detector instead of measuring along the previous direction's stops. The service tests no longer reach the real backend, and tests about a continuing ride give it a destination: a ride confirmed without one is paused to ask for it, which they had been racing. --- .../flutter/RouteTripMonitorService.kt | 72 ++++++---- .../flutter/TripMonitorFamilyRealtime.kt | 2 + .../flutter/RouteTripMonitorServiceTest.kt | 124 +++++++++++++++++- 3 files changed, 168 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index 3c0a550..1249cf0 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -78,7 +78,8 @@ class RouteTripMonitorService : Service() { private var cachedBusesRouteId: String? = null private var cachedBusesFetchedAtMs = 0L private var cachedBusReports: List = emptyList() - private var liveFetchBackoffUntilMs = 0L + private var liveStopBackoffUntilMs = 0L + private var liveBusBackoffUntilMs = 0L private var wakeLock: PowerManager.WakeLock? = null private val pollingRunnable = object : Runnable { @@ -236,6 +237,8 @@ class RouteTripMonitorService : Service() { val previousBoarding = session?.boardingStopId val previousRouteId = session?.routeId val previousRouteKey = session?.routeKey + val previousPathId = session?.pathId + val previousStopIds = session?.stops?.map { it.stopId } val previousFamily = session?.realtimeRouteIds?.toSet() val hadSession = session != null val hadDetector = session != null && detectorExists() @@ -273,7 +276,13 @@ class RouteTripMonitorService : Service() { previousRouteId != parsedSession.routeId || // Some providers leave routeId blank, so the route key // is what actually tells two routes apart. - previousRouteKey != parsedSession.routeKey + previousRouteKey != parsedSession.routeKey || + // The other direction of the same route can share a + // boarding stop, yet it is another line of stops: the + // detector measures everything along the one it was + // built with. + previousPathId != parsedSession.pathId || + previousStopIds != parsedSession.stops.map { it.stopId } val destinationChanged = previousDestination != parsedSession.destinationStopId val familyChanged = previousFamily != parsedSession.realtimeRouteIds.toSet() @@ -660,8 +669,10 @@ class RouteTripMonitorService : Service() { * ETA, and the live positions of every vehicle on the route. * * Both feeds share one rate-limit bucket with the rest of the app, so they - * are throttled by age rather than fetched on every refresh, and a rejection - * from the server backs everything off instead of hammering it. + * are throttled by age rather than fetched on every refresh, and a failed + * request backs that feed off instead of hammering the server. Each feed + * backs off on its own: the vehicle positions are what detection runs on + * when the phone has no fix, and must not go dark because the stop feed did. */ private fun updateLiveData(session: TrackingSession) { val routeIds = session.realtimeRouteIds @@ -669,13 +680,12 @@ class RouteTripMonitorService : Service() { return } val nowElapsed = SystemClock.elapsedRealtime() - if (nowElapsed < liveFetchBackoffUntilMs) { - return - } - val wantsStops = cachedLiveRouteIds != routeIds || - cachedLivePathId != session.pathId || - nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS + val wantsStops = nowElapsed >= liveStopBackoffUntilMs && ( + cachedLiveRouteIds != routeIds || + cachedLivePathId != session.pathId || + nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS + ) if (wantsStops) { val liveStops = fetchLiveStopMapFromBackend(session) // familyRealtime folds a failed request into an empty map, and a @@ -683,26 +693,30 @@ class RouteTripMonitorService : Service() { // failure rather than an empty feed — so back off rather than // spend the shared rate limit on retries. if (liveStops.isEmpty()) { - liveFetchBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS - return - } - synchronized(refreshLock) { - cachedLiveRouteIds = routeIds - cachedLivePathId = session.pathId - cachedLiveFetchedAtMs = nowElapsed - cachedLiveStops = liveStops + liveStopBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS + } else { + synchronized(refreshLock) { + cachedLiveRouteIds = routeIds + cachedLivePathId = session.pathId + cachedLiveFetchedAtMs = nowElapsed + cachedLiveStops = liveStops + } + feedBoardingEta(session, liveStops) } - feedBoardingEta(session, liveStops) } val primaryRouteId = session.routeId?.trim().orEmpty() - if (primaryRouteId.isEmpty()) { + if (primaryRouteId.isEmpty() || nowElapsed < liveBusBackoffUntilMs) { return } val wantsBuses = cachedBusesRouteId != primaryRouteId || nowElapsed - cachedBusesFetchedAtMs >= LIVE_BUS_REFRESH_INTERVAL_MS if (wantsBuses) { - val reports = fetchBusReportsFromBackend(primaryRouteId) ?: return + val reports = fetchBusReportsFromBackend(primaryRouteId) + if (reports == null) { + liveBusBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS + return + } synchronized(refreshLock) { cachedBusesRouteId = primaryRouteId cachedBusesFetchedAtMs = nowElapsed @@ -721,8 +735,10 @@ class RouteTripMonitorService : Service() { val boardingStopId = session.boardingStopId ?: return val liveStop = liveStops[boardingStopId] ?: return val etaEntry = liveStop.etaEntries.firstOrNull() + // A plate on an ETA entry is a bus still on its way, possibly ten + // minutes out; only a bus the feed places at the stop counts as here. val arriving = liveStop.seconds?.let { it <= 0 } == true || - liveStop.vehicleIds.isNotEmpty() || + liveStop.busesAtStop.isNotEmpty() || liveStop.message?.contains("進站") == true || liveStop.message?.contains("到站") == true val now = System.currentTimeMillis() @@ -1892,7 +1908,8 @@ class RouteTripMonitorService : Service() { cachedBusesRouteId = null cachedBusesFetchedAtMs = 0L cachedBusReports = emptyList() - liveFetchBackoffUntilMs = 0L + liveStopBackoffUntilMs = 0L + liveBusBackoffUntilMs = 0L synchronized(detectorLock) { detector = null latestRide = RideSnapshot() @@ -1986,8 +2003,10 @@ class RouteTripMonitorService : Service() { return reports } - /** Raw body of a backend GET; null when the request failed. */ - private fun fetchBackendJsonText(path: String): String? { + /** Raw body of a backend GET; null when the request failed. Replaced in tests. */ + internal var fetchBackendJsonText: (String) -> String? = ::httpGetBackend + + private fun httpGetBackend(path: String): String? { var connection: HttpURLConnection? = null return try { connection = URL("$BACKEND_BASE_URL$path").openConnection() as HttpURLConnection @@ -2349,8 +2368,11 @@ data class TrackingStop( data class LiveStopState( val seconds: Int?, val message: String?, + /** Every vehicle the stop's feed names, including buses still on their way. */ val vehicleIds: List, val etaEntries: List = emptyList(), + /** Only the vehicles the feed places at this stop right now. */ + val busesAtStop: List = emptyList(), ) data class LiveEtaEntry( diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt index 4e63c58..ef86fbd 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripMonitorFamilyRealtime.kt @@ -116,6 +116,7 @@ internal fun parseTripMonitorLiveStop(stop: JSONObject): LiveStopState { message = text(stop, "message"), vehicleIds = (buses + etas.mapNotNull { it.vehicleId }).distinct(), etaEntries = etas, + busesAtStop = buses, ) } @@ -131,6 +132,7 @@ private fun mergeStops(stops: List): LiveStopState { message = if (earliest == null) stops.firstNotNullOfOrNull { it.message } else null, vehicleIds = (entries.mapNotNull { it.vehicleId } + stops.flatMap { it.vehicleIds }).distinct(), etaEntries = entries, + busesAtStop = stops.flatMap { it.busesAtStop }.distinct(), ) } diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index bfb90b8..3fe440b 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -46,11 +46,16 @@ class RouteTripMonitorServiceTest { private lateinit var service: RouteTripMonitorService private lateinit var shadow: RecordingTripServiceShadow + @Volatile + private var backend: (String) -> String? = { null } + @Before fun setUp() { controller = Robolectric.buildService(RouteTripMonitorService::class.java).create() service = controller.get() shadow = Shadow.extract(service) + // No test reaches the real backend; each one says what it answers. + service.fetchBackendJsonText = { path -> backend(path) } } @After @@ -224,7 +229,7 @@ class RouteTripMonitorServiceTest { @Test fun familyChangesDoNotResetConfirmedRide() { - val initial = sessionPayload() + mapOf("routeId" to "TXG1") + val initial = tripWithDestination() + mapOf("routeId" to "TXG1") service.onStartCommand(startIntent(initial), 0, 1) service.onStartCommand(command("MARK_BOARDED"), 0, 2) assertTrue(ride(service).hasBoarded) @@ -241,7 +246,7 @@ class RouteTripMonitorServiceTest { @Test fun reportingNotBoardedClearsTheRide() { - service.onStartCommand(startIntent(), 0, 1) + service.onStartCommand(startIntent(tripWithDestination()), 0, 1) service.onStartCommand(command("MARK_BOARDED"), 0, 2) assertTrue(ride(service).hasBoarded) @@ -253,12 +258,12 @@ class RouteTripMonitorServiceTest { @Test fun switchingRouteRebuildsTheRideState() { - service.onStartCommand(startIntent(), 0, 1) + service.onStartCommand(startIntent(tripWithDestination()), 0, 1) service.onStartCommand(command("MARK_BOARDED"), 0, 2) assertTrue(ride(service).hasBoarded) service.onStartCommand( - startIntent(sessionPayload() + mapOf("routeKey" to 7, "routeName" to "7")), + startIntent(tripWithDestination() + mapOf("routeKey" to 7, "routeName" to "7")), 0, 3, ) @@ -269,7 +274,7 @@ class RouteTripMonitorServiceTest { @Test fun keepingTheSameTripDoesNotRebuildTheRide() { - val session = sessionPayload() + mapOf("routeId" to "TXG1") + val session = tripWithDestination() + mapOf("routeId" to "TXG1") service.onStartCommand(startIntent(session), 0, 1) service.onStartCommand(command("MARK_BOARDED"), 0, 2) @@ -500,6 +505,115 @@ class RouteTripMonitorServiceTest { assertEquals(rawStopId, (payload["stopId"] as Number).toLong()) } + @Test + fun theEtaRollingOverToAPlatedLaterBusStillRaisesTheQuestion() { + // The bus at the stop has no position feed, so the ETA rolling over is + // the only sign it left. The next bus is ten minutes out and its ETA + // entry carries a plate, which used to read as "a bus is here". + var stopFeed = stopFeed(eta = 0, plate = "AAA-001") + backend = { path -> if (path.endsWith("/realtime")) stopFeed else "[]" } + val session = threeStopSession() + mapOf("routeId" to "TXG1", "boardingStopId" to 1) + service.onStartCommand(startIntent(session), 0, 1) + fetchLiveData() + assertEquals(0L, ride(service).boardingCheckId) + + stopFeed = stopFeed(eta = 600, plate = "BBB-002") + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + fetchLiveData() + + assertTrue( + "the departure must be registered so the rider can be asked", + ride(service).boardingCheckId != 0L, + ) + } + + @Test + fun aFailingStopFeedDoesNotSilenceTheVehicleFeed() { + val requests = java.util.concurrent.CopyOnWriteArrayList() + backend = { path -> + requests += path + if (path.endsWith("/realtime/buses")) { + """[{"id":"AAA-001","direction":0,"lat":24.1,"lon":120.6,"time":1700000000}]""" + } else { + null + } + } + service.onStartCommand(startIntent(threeStopSession() + mapOf("routeId" to "TXG1")), 0, 1) + fetchLiveData() + + assertEquals( + listOf("AAA-001"), + ReflectionHelpers.getField>(service, "cachedBusReports").map { it.vehicleId }, + ) + + // And it keeps being fetched while the stop feed sits out its backoff. + val busFetches = requests.count { it.endsWith("/realtime/buses") } + val stopFetches = requests.size - busFetches + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + fetchLiveData() + + assertEquals(busFetches + 1, requests.count { it.endsWith("/realtime/buses") }) + assertEquals(stopFetches, requests.count { !it.endsWith("/realtime/buses") }) + } + + @Test + fun switchingDirectionRebuildsTheRideEvenWithTheSameBoardingStop() { + val outbound = threeStopSession() + mapOf("boardingStopId" to 2, "destinationStopId" to 3) + service.onStartCommand(startIntent(outbound), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + assertTrue(ride(service).hasBoarded) + + // The other direction calls at the same stops in the opposite order. + @Suppress("UNCHECKED_CAST") + val inbound = outbound + mapOf( + "pathId" to 1, + "destinationStopId" to 1, + "stops" to (outbound.getValue("stops") as List>).reversed() + .mapIndexed { index, stop -> stop + mapOf("sequence" to index + 1) }, + ) + service.onStartCommand(startIntent(inbound), 0, 3) + + assertFalse("the other direction is a different trip", ride(service).hasBoarded) + assertEquals( + "the boarding stop is found on the new direction's stops", + 1, + ride(service).boardingStopIndex, + ) + + // The same stops in a new order are a new line too, whatever the path id. + service.onStartCommand(command("MARK_BOARDED"), 0, 4) + service.onStartCommand(startIntent(inbound + mapOf("stops" to outbound.getValue("stops"))), 0, 5) + assertFalse(ride(service).hasBoarded) + } + + private fun stopFeed(eta: Int, plate: String) = + """{"paths":[{"pathid":0,"stops":[{"stopid":"1","eta":$eta,"etas":[{"plate":"$plate","eta":$eta}]}]}]}""" + + /** One round of the service's own feed fetch, after anything it already queued. */ + private fun fetchLiveData() { + val executor = ReflectionHelpers.getField(service, "ioExecutor") + executor.submit { + ReflectionHelpers.callInstanceMethod( + service, + "updateLiveData", + ReflectionHelpers.ClassParameter.from( + TrackingSession::class.java, + ReflectionHelpers.getField(service, "session"), + ), + ) + }.get(10, TimeUnit.SECONDS) + refreshRide() + } + + /** + * A ride confirmed with nowhere to get off is paused within moments to ask + * for a destination, so tests about a ride that carries on give it one. + */ + private fun tripWithDestination(): Map = sessionPayload() + mapOf( + "destinationStopId" to 1, + "destinationStopName" to "測試站", + ) + private fun threeStopSession(): Map = sessionPayload() + mapOf( "stops" to THREE_STOPS.mapIndexed { index, (lat, lon) -> mapOf( From 2cc01e70186b236a691da27b9ef7b20a0f2424f8 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Fri, 13 Nov 2026 23:01:19 +0800 Subject: [PATCH 04/11] fix(android): keep one trip's data and decisions out of the next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two further review findings, plus what turned up beside them. - Co-movement counted any vehicle of the route: the other direction's, and ones parked or deadheading beside it, all project to some position along the line. Only an unbroken run of reports on this trip's direction and on the route is evidence now, and a report that is neither breaks the run instead of being bridged over. The vehicle followed once on board has to be on the route as well. - A fetch still out when the rider switched trip was applied to whatever trip was current when it came back. The session and its detector now change hands together under the detector lock, and each response is applied under that lock only if the detector it was fetched for is still the current one — caches and back-off included. A new trip starts with both feeds due and without the last trip's back-off. - Notifications and alerts are worked out on the IO thread but acted on on the main thread, and only if neither the session nor the ride has changed since: a pause decided for the last trip, or from before the rider answered or chose a destination, is dropped. - While the app is open it does the fetching, so the detector never heard the boarding stop's ETA. The stop feed the app hands over now reaches it. Each has a test that fails when the fix is reverted. --- .../flutter/RouteTripMonitorService.kt | 333 ++++++++++++------ .../taiwanbus/flutter/TripRideDetector.kt | 17 +- .../flutter/RouteTripMonitorServiceTest.kt | 234 ++++++++++-- .../flutter/TripRideDetectorCoMovementTest.kt | 133 +++++++ 4 files changed, 579 insertions(+), 138 deletions(-) create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index 1249cf0..e64c779 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -60,7 +60,7 @@ class RouteTripMonitorService : Service() { */ private var detector: TripRideDetector? = null private var latestRide = RideSnapshot() - private var detectorLock = Any() + private val detectorLock = Any() private var boardingAlertSent = false private var boardingCheckNotificationId = 0L private var destinationSetupPromptSent = false @@ -77,7 +77,6 @@ class RouteTripMonitorService : Service() { private var cachedLiveStops: Map = emptyMap() private var cachedBusesRouteId: String? = null private var cachedBusesFetchedAtMs = 0L - private var cachedBusReports: List = emptyList() private var liveStopBackoffUntilMs = 0L private var liveBusBackoffUntilMs = 0L private var wakeLock: PowerManager.WakeLock? = null @@ -243,7 +242,6 @@ class RouteTripMonitorService : Service() { val hadSession = session != null val hadDetector = session != null && detectorExists() if (AppRuntimeStateStore.isTripMonitorPausedFor(this, parsedSession)) { - session = parsedSession appInForeground = parsedSession.appInForeground stopTracking() return START_NOT_STICKY @@ -254,19 +252,10 @@ class RouteTripMonitorService : Service() { session = parsedSession, nowMs = startedAtMs, ) - session = parsedSession appInForeground = parsedSession.appInForeground val providedStops = parsedSession.stops.mapNotNull { stop -> stop.liveData?.let { stop.stopId to it } }.toMap() - if (providedStops.isNotEmpty()) { - synchronized(refreshLock) { - cachedLiveRouteIds = parsedSession.realtimeRouteIds - cachedLivePathId = parsedSession.pathId - cachedLiveFetchedAtMs = SystemClock.elapsedRealtime() - cachedLiveStops = providedStops - } - } if (!appInForeground && lastWentBackgroundAtMs == 0L) { lastWentBackgroundAtMs = System.currentTimeMillis() } @@ -291,27 +280,60 @@ class RouteTripMonitorService : Service() { // detector must exist before anything can consult it. A ride the // user already confirmed is never rebuilt out from under them. val rideAlreadyConfirmed = currentRideSnapshot().hasBoarded - if ( - tripOriginChanged || + val startsNewTrip = tripOriginChanged || (familyChanged && !rideAlreadyConfirmed) || !hadDetector - ) { + if (startsNewTrip) { boardingAlertSent = false destinationSetupPromptSent = false arrivalDetectedAtMs = 0L destinationAlertStage = 0 overshootAlertSent = false cancelBoardingCheckPrompt() - synchronized(detectorLock) { - detector = createDetector(parsedSession).also { created -> + } else if (destinationChanged) { + // Choosing a destination after boarding must not unlock the + // current vehicle and fall back to the next bus at the + // boarding stop. Only the destination alert lifecycle is new. + destinationSetupPromptSent = false + arrivalDetectedAtMs = 0L + destinationAlertStage = 0 + overshootAlertSent = false + } + // The session and the detector built for it change hands in one + // step: a refresh still fetching for the previous trip looks + // for exactly this before it applies what it brought back. + synchronized(detectorLock) { + session = parsedSession + val tracking = if (startsNewTrip) { + createDetector(parsedSession).also { created -> if (continuesBoardedRide) { // Choosing where to get off, in answer to "you // have boarded", is the rider confirming it. created.confirmBoardedByUser(startedAtMs) } + detector = created + latestRide = RideSnapshot() + // It starts knowing nothing, so both feeds are due + // at once, and the last trip's failures are not its + // own to sit out. + forgetLiveFeeds() + } + } else { + detector?.also { it.updateDestination(parsedSession.destinationStopId) } + } + if (providedStops.isNotEmpty()) { + synchronized(refreshLock) { + cachedLiveRouteIds = parsedSession.realtimeRouteIds + cachedLivePathId = parsedSession.pathId + cachedLiveFetchedAtMs = SystemClock.elapsedRealtime() + cachedLiveStops = providedStops } - latestRide = RideSnapshot() + // While the app is open it does the fetching, and this is + // all the detector hears of the boarding stop's ETA. + tracking?.let { feedBoardingEta(it, parsedSession, providedStops) } } + } + if (startsNewTrip) { if (continuesBoardedRide) { boardingAlertSent = true AppRuntimeStateStore.clearPausedTripMonitor(this) @@ -324,19 +346,6 @@ class RouteTripMonitorService : Service() { latestLocation = createSessionLocation(parsedSession) ?: latestLocation latestLocation?.let(::onUserLocation) } - } else { - // Choosing a destination after boarding must not unlock the - // current vehicle and fall back to the next bus at the - // boarding stop. Only the destination alert lifecycle is new. - if (destinationChanged) { - destinationSetupPromptSent = false - arrivalDetectedAtMs = 0L - destinationAlertStage = 0 - overshootAlertSent = false - } - synchronized(detectorLock) { - detector?.updateDestination(parsedSession.destinationStopId) - } } createAlertNotificationChannel() @@ -351,6 +360,51 @@ class RouteTripMonitorService : Service() { private fun detectorExists(): Boolean = synchronized(detectorLock) { detector != null } + /** A trip as one refresh sees it: the session, and the detector built for it. */ + private class TrackedTrip(val session: TrackingSession, val detector: TripRideDetector) + + private fun currentTrip(): TrackedTrip? = synchronized(detectorLock) { + val currentSession = session ?: return null + val currentDetector = detector ?: return null + TrackedTrip(currentSession, currentDetector) + } + + /** + * Applies what a request brought back for [trip], unless the trip has been + * replaced while the request was out; returns whether it was applied. + * + * The rider can open another route, or the other direction, while a request + * is in flight. The check and [apply] share the lock the detector is + * replaced under, so what was fetched for one trip never lands in the next. + */ + private fun applyToTrip(trip: TrackedTrip, apply: () -> Unit): Boolean = + synchronized(detectorLock) { + if (detector !== trip.detector) { + return false + } + apply() + true + } + + /** + * Runs [action] on the main thread unless by then [trackedSession] has been + * replaced, or the ride has moved on from [ride] — the rider has answered + * the boarding question, say. Sessions are only ever replaced on the main + * thread and answers arrive there, so once the check has passed neither can + * happen before [action] has run. + */ + private fun postIfStillTracking( + trackedSession: TrackingSession, + ride: RideSnapshot? = null, + action: () -> Unit, + ) { + mainHandler.post { + if (session === trackedSession && (ride == null || currentRideSnapshot() === ride)) { + action() + } + } + } + /** * Whether [session] is the ride that was paused for want of a destination, * now given one. @@ -378,6 +432,11 @@ class RouteTripMonitorService : Service() { } override fun onDestroy() { + // A refresh still in flight has no trip left to apply itself to. + synchronized(detectorLock) { + session = null + detector = null + } mainHandler.removeCallbacksAndMessages(null) runCatching { if (::fusedLocationClient.isInitialized) { @@ -589,7 +648,9 @@ class RouteTripMonitorService : Service() { } private fun refreshNotification(force: Boolean = false) { - val currentSession = session ?: return + if (session == null) { + return + } synchronized(refreshLock) { val now = SystemClock.elapsedRealtime() if (refreshInFlight) { @@ -609,32 +670,7 @@ class RouteTripMonitorService : Service() { val submitted = runCatching { ioExecutor.execute { try { - runCatching { - if (session !== currentSession) return@runCatching - if (force) { - // Rich notifications (including OEM integrations) are - // built off the main thread, after foreground promotion. - notificationManager.notify( - TRACKING_NOTIFICATION_ID, - buildTrackingNotification(buildStartupSnapshot(currentSession)), - ) - } - // Fetched before the snapshot: everything the detector - // learns arrives through here. - updateLiveData(currentSession) - // Advanced on every refresh, not just when the bus feed - // came back, because the question timing and the user's - // own progress move independently of it. - refreshRideSnapshot(System.currentTimeMillis()) - val trackingSnapshot = buildSnapshot( - currentSession, - latestLocation ?: createSessionLocation(currentSession), - ) - if (session !== currentSession) return@runCatching - val notification = buildTrackingNotification(trackingSnapshot) - notificationManager.notify(TRACKING_NOTIFICATION_ID, notification) - maybeSendTripAlerts(currentSession, trackingSnapshot) - } + runCatching { refreshTrip(force) } } finally { var shouldRefreshAgain = false synchronized(refreshLock) { @@ -664,6 +700,49 @@ class RouteTripMonitorService : Service() { } } + /** + * One refresh, on the IO thread: fetch, let the detector look again, and + * hand what is to be shown and alerted to the main thread. + * + * The trip is read here rather than where the refresh was asked for, and is + * checked again after every wait, because the main thread can replace it at + * any moment. + */ + private fun refreshTrip(force: Boolean) { + val trip = currentTrip() ?: return + val trackedSession = trip.session + if (force) { + // Rich notifications (including OEM integrations) are built off the + // main thread, after foreground promotion. + val startup = buildTrackingNotification(buildStartupSnapshot(trackedSession)) + postIfStillTracking(trackedSession) { + notificationManager.notify(TRACKING_NOTIFICATION_ID, startup) + } + } + // Fetched before the snapshot: everything the detector learns arrives + // through here. + if (!updateLiveData(trip)) { + return + } + // Advanced on every refresh, not just when the bus feed came back, + // because the question timing and the user's own progress move + // independently of it. + val ride = advanceRide(trip, System.currentTimeMillis()) ?: return + val trackingSnapshot = buildSnapshot( + trackedSession, + latestLocation ?: createSessionLocation(trackedSession), + ride, + ) + val notification = buildTrackingNotification(trackingSnapshot) + // Alerts can pause or stop the trip, so they are decided where trips + // are replaced and the rider's answers arrive: one worked out for the + // last trip, or from before "I am not on it", must not be acted on. + postIfStillTracking(trackedSession, ride) { + notificationManager.notify(TRACKING_NOTIFICATION_ID, notification) + maybeSendTripAlerts(trackedSession, trackingSnapshot) + } + } + /** * Pulls whatever the detector needs from the backend: the boarding stop's * ETA, and the live positions of every vehicle on the route. @@ -673,62 +752,79 @@ class RouteTripMonitorService : Service() { * request backs that feed off instead of hammering the server. Each feed * backs off on its own: the vehicle positions are what detection runs on * when the phone has no fix, and must not go dark because the stop feed did. + * + * Returns false when [trip] was replaced while a request was out; nothing + * fetched after that point has been applied, and the caller should stop. */ - private fun updateLiveData(session: TrackingSession) { + private fun updateLiveData(trip: TrackedTrip): Boolean { + val session = trip.session val routeIds = session.realtimeRouteIds if (routeIds.isEmpty()) { - return + return true } + val primaryRouteId = session.routeId?.trim().orEmpty() val nowElapsed = SystemClock.elapsedRealtime() + val (wantsStops, wantsBuses) = synchronized(refreshLock) { + val stopsDue = nowElapsed >= liveStopBackoffUntilMs && ( + cachedLiveRouteIds != routeIds || + cachedLivePathId != session.pathId || + nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS + ) + val busesDue = primaryRouteId.isNotEmpty() && + nowElapsed >= liveBusBackoffUntilMs && ( + cachedBusesRouteId != primaryRouteId || + nowElapsed - cachedBusesFetchedAtMs >= LIVE_BUS_REFRESH_INTERVAL_MS + ) + stopsDue to busesDue + } - val wantsStops = nowElapsed >= liveStopBackoffUntilMs && ( - cachedLiveRouteIds != routeIds || - cachedLivePathId != session.pathId || - nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS - ) if (wantsStops) { val liveStops = fetchLiveStopMapFromBackend(session) - // familyRealtime folds a failed request into an empty map, and a - // route with stops to look up that answers with nothing is a - // failure rather than an empty feed — so back off rather than - // spend the shared rate limit on retries. - if (liveStops.isEmpty()) { - liveStopBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS - } else { - synchronized(refreshLock) { - cachedLiveRouteIds = routeIds - cachedLivePathId = session.pathId - cachedLiveFetchedAtMs = nowElapsed - cachedLiveStops = liveStops + val stillTracked = applyToTrip(trip) { + // familyRealtime folds a failed request into an empty map, and a + // route with stops to look up that answers with nothing is a + // failure rather than an empty feed — so back off rather than + // spend the shared rate limit on retries. + if (liveStops.isEmpty()) { + synchronized(refreshLock) { + liveStopBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS + } + } else { + synchronized(refreshLock) { + cachedLiveRouteIds = routeIds + cachedLivePathId = session.pathId + cachedLiveFetchedAtMs = nowElapsed + cachedLiveStops = liveStops + } + feedBoardingEta(trip.detector, session, liveStops) } - feedBoardingEta(session, liveStops) + } + if (!stillTracked) { + return false } } - val primaryRouteId = session.routeId?.trim().orEmpty() - if (primaryRouteId.isEmpty() || nowElapsed < liveBusBackoffUntilMs) { - return - } - val wantsBuses = cachedBusesRouteId != primaryRouteId || - nowElapsed - cachedBusesFetchedAtMs >= LIVE_BUS_REFRESH_INTERVAL_MS if (wantsBuses) { val reports = fetchBusReportsFromBackend(primaryRouteId) - if (reports == null) { - liveBusBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS - return - } - synchronized(refreshLock) { - cachedBusesRouteId = primaryRouteId - cachedBusesFetchedAtMs = nowElapsed - cachedBusReports = reports - } - synchronized(detectorLock) { - detector?.onBusReports(reports) + return applyToTrip(trip) { + if (reports == null) { + synchronized(refreshLock) { + liveBusBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS + } + } else { + synchronized(refreshLock) { + cachedBusesRouteId = primaryRouteId + cachedBusesFetchedAtMs = nowElapsed + } + trip.detector.onBusReports(reports) + } } } + return true } private fun feedBoardingEta( + target: TripRideDetector, session: TrackingSession, liveStops: Map, ) { @@ -743,7 +839,7 @@ class RouteTripMonitorService : Service() { liveStop.message?.contains("到站") == true val now = System.currentTimeMillis() synchronized(detectorLock) { - detector?.onBoardingStopEta( + target.onBoardingStopEta( etaSeconds = liveStop.seconds ?: etaEntry?.seconds, arriving = arriving, nowMs = now, @@ -757,6 +853,17 @@ class RouteTripMonitorService : Service() { } } + /** + * Lets [trip]'s detector look again and returns what it now makes of the + * ride; null when the trip has been replaced. + */ + private fun advanceRide(trip: TrackedTrip, nowMs: Long): RideSnapshot? = synchronized(detectorLock) { + if (detector !== trip.detector) { + return null + } + trip.detector.snapshot(nowMs).also { latestRide = it } + } + private fun currentRideSnapshot(): RideSnapshot = synchronized(detectorLock) { latestRide } /** @@ -767,13 +874,13 @@ class RouteTripMonitorService : Service() { private fun buildSnapshot( session: TrackingSession, location: Location?, + ride: RideSnapshot, ): TrackingSnapshot { val liveStops = fetchLiveStopMap(session) if (this.session !== session) return buildStartupSnapshot(session) // A missing fix is not an early exit: the detector can still raise the // boarding question without any location at all, and the notification // is built from what it knows rather than from a nearest-stop guess. - val ride = currentRideSnapshot() val nearestIndex = location?.let { findNearestStopIndex(session.stops, it) } ?: -1 val nearestStop = session.stops.getOrNull(nearestIndex) val nearestLiveStop = nearestStop?.let { liveStops[it.stopId] } @@ -1891,29 +1998,23 @@ class RouteTripMonitorService : Service() { // involve other processes (location and notification services). stopSelf() foregroundStarted = false - session = null latestLocation = null boardingAlertSent = false destinationSetupPromptSent = false arrivalDetectedAtMs = 0L destinationAlertStage = 0 overshootAlertSent = false - refreshInFlight = false - refreshPending = false - lastRefreshStartedAtMs = 0L - cachedLiveRouteIds = emptyList() - cachedLivePathId = null - cachedLiveFetchedAtMs = 0L - cachedLiveStops = emptyMap() - cachedBusesRouteId = null - cachedBusesFetchedAtMs = 0L - cachedBusReports = emptyList() - liveStopBackoffUntilMs = 0L - liveBusBackoffUntilMs = 0L synchronized(detectorLock) { + session = null detector = null latestRide = RideSnapshot() } + synchronized(refreshLock) { + refreshInFlight = false + refreshPending = false + lastRefreshStartedAtMs = 0L + } + forgetLiveFeeds() cancelBoardingCheckPrompt() releaseWakeLock() mainHandler.removeCallbacksAndMessages(null) @@ -1934,6 +2035,18 @@ class RouteTripMonitorService : Service() { } } + /** Forgets what was fetched and when, which makes both feeds due at once. */ + private fun forgetLiveFeeds() = synchronized(refreshLock) { + cachedLiveRouteIds = emptyList() + cachedLivePathId = null + cachedLiveFetchedAtMs = 0L + cachedLiveStops = emptyMap() + cachedBusesRouteId = null + cachedBusesFetchedAtMs = 0L + liveStopBackoffUntilMs = 0L + liveBusBackoffUntilMs = 0L + } + /** * The cached stop feed. Fetching happens in [updateLiveData] so that the * snapshot builder never blocks on the network and both feeds are diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index 23f5101..e29dc58 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -377,6 +377,11 @@ internal class TripRideDetector( * Standing at the stop while the bus drives off makes the gap grow by the * bus's whole travel, and walking beside a bus stuck in traffic fails the * speed floor, so neither counts. + * + * Only an unbroken run of reports made on this trip's direction, on the + * route, is evidence. The feed lists every vehicle of the route — the other + * direction's, and ones parked or deadheading beside it — and each of those + * still projects to some position along this line. */ private fun detectCoMovement(nowMs: Long): String? { if (userTrack.size < 2) { @@ -389,6 +394,12 @@ internal class TripRideDetector( } pairs.clear() track.forEach { report -> + if (!report.samePath || !report.onRoute) { + // Whatever came before is not joined to what follows as + // though the bus had run on through. + pairs.clear() + return@forEach + } if (report.receivedAtMs < nowMs - COMOVE_WINDOW_MS) { return@forEach } @@ -623,7 +634,9 @@ internal class TripRideDetector( /** * Once on board, the vehicle to follow is the one the user is inside. Its * reported position trails the user's own fix by the feed's delay, so it is - * expected a little behind and never far ahead. + * expected a little behind and never far ahead. It is also on this trip's + * direction and on the route: a vehicle parked beside the route projects + * onto it too, and can be the nearest one there is. */ private fun resolveTrackedVehicle( vehicles: List, @@ -642,7 +655,7 @@ internal class TripRideDetector( return locked.vehicleId } return vehicles - .filter { it.samePath } + .filter { it.samePath && it.onRoute } .filter { usableUser.alongMeters - it.alongMeters in -TRACK_MAX_AHEAD_METERS..TRACK_PICK_BEHIND_METERS } .minByOrNull { abs(usableUser.alongMeters - it.alongMeters) } ?.vehicleId diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index 3fe440b..4176409 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -13,8 +13,10 @@ import android.os.Looper import android.os.SystemClock import org.robolectric.Shadows.shadowOf import org.robolectric.shadows.ShadowSystemClock +import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -62,8 +64,7 @@ class RouteTripMonitorServiceTest { fun tearDown() { service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 99) controller.destroy() - val executor = ReflectionHelpers.getField(service, "ioExecutor") - assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)) + assertTrue(ioExecutor().awaitTermination(5, TimeUnit.SECONDS)) } @Test @@ -538,13 +539,11 @@ class RouteTripMonitorServiceTest { null } } - service.onStartCommand(startIntent(threeStopSession() + mapOf("routeId" to "TXG1")), 0, 1) + val session = threeStopSession() + mapOf("routeId" to "TXG1", "boardingStopId" to 1) + service.onStartCommand(startIntent(session), 0, 1) fetchLiveData() - assertEquals( - listOf("AAA-001"), - ReflectionHelpers.getField>(service, "cachedBusReports").map { it.vehicleId }, - ) + assertEquals("the bus at the boarding stop must be seen", "AAA-001", ride(service).nextBusVehicleId) // And it keeps being fetched while the stop feed sits out its backoff. val busFetches = requests.count { it.endsWith("/realtime/buses") } @@ -563,14 +562,7 @@ class RouteTripMonitorServiceTest { service.onStartCommand(command("MARK_BOARDED"), 0, 2) assertTrue(ride(service).hasBoarded) - // The other direction calls at the same stops in the opposite order. - @Suppress("UNCHECKED_CAST") - val inbound = outbound + mapOf( - "pathId" to 1, - "destinationStopId" to 1, - "stops" to (outbound.getValue("stops") as List>).reversed() - .mapIndexed { index, stop -> stop + mapOf("sequence" to index + 1) }, - ) + val inbound = reversed(outbound) + mapOf("destinationStopId" to 1) service.onStartCommand(startIntent(inbound), 0, 3) assertFalse("the other direction is a different trip", ride(service).hasBoarded) @@ -586,25 +578,215 @@ class RouteTripMonitorServiceTest { assertFalse(ride(service).hasBoarded) } - private fun stopFeed(eta: Int, plate: String) = - """{"paths":[{"pathid":0,"stops":[{"stopid":"1","eta":$eta,"etas":[{"plate":"$plate","eta":$eta}]}]}]}""" + @Test + fun aFetchStillInFlightForTheLastTripDoesNotReachTheNextOne() { + val first = threeStopSession() + mapOf("routeId" to "TXG1", "boardingStopId" to 2) + val anotherRoute = first + mapOf("routeKey" to 2, "routeId" to "TXG2") + val theOtherDirection = reversed(first) + for (next in listOf(anotherRoute, theOtherDirection)) { + val firstStop = stopsOf(next).first() + val oldFetchStarted = CountDownLatch(1) + val releaseOldFetch = CountDownLatch(1) + val answersTheNewTrip = AtomicBoolean(false) + backend = { path -> + val wantsBuses = path.endsWith("/realtime/buses") + when { + // The new trip: its bus is a stop back, ten minutes out. + answersTheNewTrip.get() && wantsBuses -> busFeed("NEW-001", firstStop) + answersTheNewTrip.get() -> stopFeed(eta = 600, plate = "NEW-001", stopId = 2) + // The old one: a bus pulling in at the boarding stop. + wantsBuses -> busFeed("OLD-001", stopsOf(first)[1]) + else -> { + oldFetchStarted.countDown() + releaseOldFetch.await(10, TimeUnit.SECONDS) + stopFeed(eta = 0, plate = "OLD-001", stopId = 2) + } + } + } + service.onStartCommand(startIntent(first), 0, 1) + assertTrue("the first trip's fetch never started", oldFetchStarted.await(10, TimeUnit.SECONDS)) + + // The rider switches while that request is still out. Both trips + // share stops, so everything it brings back would fit the new one. + service.onStartCommand(startIntent(next), 0, 2) + releaseOldFetch.countDown() + awaitQueuedRefreshes() + answersTheNewTrip.set(true) + fetchLiveData() + + assertEquals( + "the new trip must see its own vehicles, at once, and none of the old trip's", + "NEW-001", + ride(service).nextBusVehicleId, + ) + assertEquals( + "the old trip's ETA must not count as a bus arriving, and so later as one leaving", + 0L, + ride(service).boardingCheckId, + ) + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 3) + } + } + + @Test + fun aRebuiltTripHearsOfTheVehiclesOnItsFirstRefresh() { + // One bus at the middle stop, running the direction about to be chosen. + backend = { path -> + if (path.endsWith("/realtime/buses")) { + """[{"id":"AAA-001","direction":1,"lat":24.1045,"lon":120.6,"time":1700000000}]""" + } else { + null + } + } + val outbound = threeStopSession() + mapOf("routeId" to "TXG1", "boardingStopId" to 2) + service.onStartCommand(startIntent(outbound), 0, 1) + fetchLiveData() + assertNull("it is the other direction's bus", ride(service).nextBusVehicleId) + + // The route's vehicles were fetched a moment ago, but for a detector + // that no longer exists; the new one must not wait out that interval. + service.onStartCommand(startIntent(reversed(outbound)), 0, 2) + fetchLiveData() + + assertEquals("AAA-001", ride(service).nextBusVehicleId) + } + + @Test + fun whatARefreshDecidedForAReplacedTripIsNotActedOn() { + // On board with nowhere to get off: this trip's refresh ends by pausing + // the monitor to ask for a destination. + service.onStartCommand(startIntent(threeStopSession() + mapOf("boardingStopId" to 1)), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + fetchLiveData() + + // Before that is acted on, the rider opens another route. + val next = tripWithDestination() + mapOf("routeKey" to 7, "routeName" to "7") + service.onStartCommand(startIntent(next), 0, 3) + shadowOf(Looper.getMainLooper()).idle() + + assertNull( + "the pause meant for the old trip must not land on the new one", + AppRuntimeStateStore.loadPausedTripMonitor(service), + ) + assertEquals(7, ReflectionHelpers.getField(service, "session").routeKey) + } + + @Test + fun whatARefreshDecidedBeforeTheRiderAnsweredIsNotActedOn() { + // Taken for on board with nowhere to get off, so this refresh too ends + // by pausing the monitor to ask for a destination. + service.onStartCommand(startIntent(threeStopSession() + mapOf("boardingStopId" to 1)), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + fetchLiveData() + + // Before that is acted on, the rider says they are not on the bus. + beforeTheNextRefresh { + service.onStartCommand(command("NOT_BOARDED"), 0, 3) + shadowOf(Looper.getMainLooper()).idle() + } + + assertNull( + "a rider still waiting must not have the monitor paused as though on board", + AppRuntimeStateStore.loadPausedTripMonitor(service), + ) + assertNotNull(ReflectionHelpers.getField(service, "session")) + } + + @Test + fun whatARefreshDecidedBeforeADestinationWasChosenIsNotActedOn() { + val boarded = threeStopSession() + mapOf("boardingStopId" to 1) + service.onStartCommand(startIntent(boarded), 0, 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + fetchLiveData() + + // Before the pause for want of a destination lands, the rider picks one. + beforeTheNextRefresh { + val chosen = mapOf("destinationStopId" to 3, "destinationStopName" to "三站") + service.onStartCommand(startIntent(boarded + chosen), 0, 3) + shadowOf(Looper.getMainLooper()).idle() + } + + assertNull( + "the ride now has somewhere to get off, and must carry on to it", + AppRuntimeStateStore.loadPausedTripMonitor(service), + ) + assertEquals(3, ReflectionHelpers.getField(service, "session").destinationStopId) + } + + @Test + fun theStopFeedTheAppHandsOverWhileOpenReachesTheDetector() { + // With the app open the route screen sends its own copy of the stop feed + // every few seconds, and the monitor does not fetch one of its own. + fun update(eta: Int, plate: String): Intent { + val session = threeStopSession() + val stops = stopsOf(session).map { stop -> + stop + mapOf( + "liveData" to mapOf("eta" to eta, "etas" to listOf(mapOf("plate" to plate, "eta" to eta))), + ) + } + return startIntent(session + mapOf("boardingStopId" to 1, "stops" to stops)) + } + service.onStartCommand(update(eta = 0, plate = "AAA-001"), 0, 1) + refreshRide() + assertEquals(0L, ride(service).boardingCheckId) + + service.onStartCommand(update(eta = 600, plate = "BBB-002"), 0, 2) + refreshRide() + + assertTrue( + "the bus leaving must be noticed from the app's copy of the feed too", + ride(service).boardingCheckId != 0L, + ) + } + + /** The other direction of [session]: the same stops, called at in the opposite order. */ + private fun reversed(session: Map): Map = session + mapOf( + "pathId" to 1, + "stops" to stopsOf(session).reversed() + .mapIndexed { index, stop -> stop + mapOf("sequence" to index + 1) }, + ) + + @Suppress("UNCHECKED_CAST") + private fun stopsOf(session: Map) = session.getValue("stops") as List> + + private fun stopFeed(eta: Int, plate: String, stopId: Int = 1) = + """{"paths":[{"stops":[{"stopid":"$stopId","eta":$eta,"etas":[{"plate":"$plate","eta":$eta}]}]}]}""" + + /** One vehicle at [stop], reporting no direction, so that it counts for either. */ + private fun busFeed(vehicleId: String, stop: Map) = + """[{"id":"$vehicleId","lat":${stop["lat"]},"lon":${stop["lon"]},"time":1700000000}]""" - /** One round of the service's own feed fetch, after anything it already queued. */ + /** One refresh as the service runs it, after anything it already queued. */ private fun fetchLiveData() { - val executor = ReflectionHelpers.getField(service, "ioExecutor") - executor.submit { + ioExecutor().submit { ReflectionHelpers.callInstanceMethod( service, - "updateLiveData", - ReflectionHelpers.ClassParameter.from( - TrackingSession::class.java, - ReflectionHelpers.getField(service, "session"), - ), + "refreshTrip", + ReflectionHelpers.ClassParameter.from(Boolean::class.javaPrimitiveType, false), ) }.get(10, TimeUnit.SECONDS) - refreshRide() } + private fun awaitQueuedRefreshes() { + ioExecutor().submit {}.get(10, TimeUnit.SECONDS) + } + + /** + * Runs [block] with the refresh it sets off held back, so that what the + * last refresh left for the main thread is all that can happen in it. + */ + private fun beforeTheNextRefresh(block: () -> Unit) { + val held = CountDownLatch(1) + ioExecutor().execute { held.await(10, TimeUnit.SECONDS) } + try { + block() + } finally { + held.countDown() + } + } + + private fun ioExecutor(): ExecutorService = ReflectionHelpers.getField(service, "ioExecutor") + /** * A ride confirmed with nowhere to get off is paused within moments to ask * for a destination, so tests about a ride that carries on give it one. diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt new file mode 100644 index 0000000..29662ab --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt @@ -0,0 +1,133 @@ +package tw.avianjay.taiwanbus.flutter + +import kotlin.math.PI +import kotlin.math.cos +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which vehicles may confirm a ride. + * + * The position feed lists every vehicle of the route: this direction's, the + * other direction's, and ones parked or deadheading beside it. All of them + * project to some position along the trip's stops, so a vehicle that merely + * moves in step with the phone used to be taken for the rider's bus whatever + * direction it reported and however far off the route it was. + * + * The route is a straight northbound line of five stops 500 m apart. Every ride + * here covers 300 m, short of what the rider's own progress would need to count + * by itself, so the vehicle is the only thing that can confirm it. + */ +class TripRideDetectorCoMovementTest { + @Test + fun ridingABusOfThisDirectionIsDetected() { + val ride = rideAlongside { seconds -> report(seconds, along = 5.0 * seconds) } + + assertTrue("the scenario has to be one a real bus would confirm", ride.hasBoarded) + assertEquals(BoardingEvidence.BUS_CO_MOVEMENT, ride.evidence) + assertEquals(BUS, ride.trackedVehicleId) + } + + @Test + fun aBusOfTheOtherDirectionIsNotTheRidersBus() { + val ride = rideAlongside { seconds -> report(seconds, along = 5.0 * seconds, pathId = 1) } + + assertFalse("a bus going the other way cannot be the one the rider is on", ride.hasBoarded) + assertEquals(BoardingEvidence.NONE, ride.evidence) + assertNull(ride.trackedVehicleId) + } + + @Test + fun aVehicleOffTheRouteIsNotTheRidersBus() { + // 350 m to the side: deadheading down a parallel street. + val ride = rideAlongside { seconds -> + report(seconds, along = 5.0 * seconds, offsetMeters = 350.0) + } + + assertFalse("a vehicle that is not on the route cannot be the rider's bus", ride.hasBoarded) + assertNull(ride.trackedVehicleId) + } + + @Test + fun reportsEitherSideOfAStretchOnTheOtherDirectionDoNotAddUp() { + // On this direction, then one report on the other, then back: too few + // reports on either side to confirm anything, and the two sides are not + // one journey. + val ride = rideAlongside { seconds -> + report(seconds, along = 5.0 * seconds, pathId = if (seconds == 40L) 1 else 0) + } + + assertFalse(ride.hasBoarded) + } + + @Test + fun aVehicleParkedBesideTheRouteIsNotPickedAsTheBusBeingRidden() { + val detector = newDetector() + // The parked vehicle is level with the rider; the bus they are on was + // last reported 400 m back, as the feed's delay makes usual. + detector.onBusReports( + listOf( + report(0, along = 1_000.0, offsetMeters = 350.0, vehicleId = "PARKED"), + report(0, along = 600.0), + ), + ) + detector.onUserLocation(fix(0, along = 1_000.0)) + detector.confirmBoardedByUser(at(0)) + + assertEquals(BUS, detector.snapshot(at(0)).trackedVehicleId) + } + + /** The rider travels 300 m up the route in a minute, beside whatever [vehicle] reports. */ + private fun rideAlongside(vehicle: (Long) -> RideBusReport): RideSnapshot { + val detector = newDetector() + var snapshot = RideSnapshot() + for (seconds in 0L..60L step 20L) { + detector.onBusReports(listOf(vehicle(seconds))) + detector.onUserLocation(fix(seconds, along = 5.0 * seconds)) + snapshot = detector.snapshot(at(seconds)) + } + return snapshot + } + + private fun newDetector() = TripRideDetector( + stops = (0..4).map { index -> + val (lat, lon) = point(index * 500.0, 0.0) + TrackingStop(stopId = 900 + index, stopName = "stop-$index", sequence = index, lat = lat, lon = lon) + }, + pathId = 0, + boardingStopId = 900, + destinationStopId = 904, + ) + + private fun fix(seconds: Long, along: Double): RideGeoPoint { + val (lat, lon) = point(along, 0.0) + return RideGeoPoint(lat, lon, at(seconds), accuracyMeters = 12f, speedMetersPerSecond = 5f) + } + + private fun report( + seconds: Long, + along: Double, + pathId: Int = 0, + offsetMeters: Double = 0.0, + vehicleId: String = BUS, + ): RideBusReport { + val (lat, lon) = point(along, offsetMeters) + return RideBusReport(vehicleId, pathId, lat, lon, gpsTimeMs = at(seconds), receivedAtMs = at(seconds)) + } + + private fun at(seconds: Long): Long = 1_700_000_000_000L + seconds * 1_000L + + /** [offsetMeters] is measured east of the route, which runs due north. */ + private fun point(alongMeters: Double, offsetMeters: Double): Pair { + val metersPerDegreeLat = 6_371_000.0 * PI / 180.0 + return 24.0 + alongMeters / metersPerDegreeLat to + 120.5 + offsetMeters / (metersPerDegreeLat * cos(24.0 * PI / 180.0)) + } + + private companion object { + const val BUS = "AAA-001" + } +} From c8d42d1a79883b73467ceaa30ad01368265ecdcf Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sat, 14 Nov 2026 00:12:19 +0800 Subject: [PATCH 05/11] fix(android): preserve skipped-stop alerts and newer trip feeds Start the arrival baseline when progress skips directly past the destination, retaining overshoot reminders and the arrival grace pause. Version app-provided stop data so older in-flight responses cannot overwrite its cache, invent departures, or apply obsolete backoff; keep valid vehicle results. Add four service regression tests across SDK 28 and 34. All 122 Android unit tests pass. --- .../flutter/RouteTripMonitorService.kt | 22 ++- .../flutter/RouteTripMonitorServiceTest.kt | 137 +++++++++++++++++- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index e64c779..0d762fe 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -61,6 +61,8 @@ class RouteTripMonitorService : Service() { private var detector: TripRideDetector? = null private var latestRide = RideSnapshot() private val detectorLock = Any() + /** Under detectorLock: newer app-provided stop data supersedes pending stop requests. */ + private var stopFeedRevision = 0L private var boardingAlertSent = false private var boardingCheckNotificationId = 0L private var destinationSetupPromptSent = false @@ -322,6 +324,7 @@ class RouteTripMonitorService : Service() { detector?.also { it.updateDestination(parsedSession.destinationStopId) } } if (providedStops.isNotEmpty()) { + stopFeedRevision++ synchronized(refreshLock) { cachedLiveRouteIds = parsedSession.realtimeRouteIds cachedLivePathId = parsedSession.pathId @@ -361,12 +364,16 @@ class RouteTripMonitorService : Service() { private fun detectorExists(): Boolean = synchronized(detectorLock) { detector != null } /** A trip as one refresh sees it: the session, and the detector built for it. */ - private class TrackedTrip(val session: TrackingSession, val detector: TripRideDetector) + private class TrackedTrip( + val session: TrackingSession, + val detector: TripRideDetector, + val stopFeedRevision: Long, + ) private fun currentTrip(): TrackedTrip? = synchronized(detectorLock) { val currentSession = session ?: return null val currentDetector = detector ?: return null - TrackedTrip(currentSession, currentDetector) + TrackedTrip(currentSession, currentDetector, stopFeedRevision) } /** @@ -781,6 +788,12 @@ class RouteTripMonitorService : Service() { if (wantsStops) { val liveStops = fetchLiveStopMapFromBackend(session) val stillTracked = applyToTrip(trip) { + // The same detector can receive a newer foreground update while + // this request is out. Discard its stop data and back-off only; + // the independently fetched vehicle positions remain useful. + if (stopFeedRevision != trip.stopFeedRevision) { + return@applyToTrip + } // familyRealtime folds a failed request into an empty map, and a // route with stops to look up that answers with nothing is a // failure rather than an empty feed — so back off rather than @@ -1574,6 +1587,11 @@ class RouteTripMonitorService : Service() { snapshot: TrackingSnapshot, ) { if (snapshot.passedDestinationByStops != null) { + // Fixes can skip the destination entirely. Passing it is also an + // arrival baseline, without restarting a countdown already running. + if (arrivalDetectedAtMs == 0L) { + arrivalDetectedAtMs = System.currentTimeMillis() + } if (maybeSendOvershotAlert(session, snapshot)) { return } diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index 4176409..c8a7922 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -739,6 +739,138 @@ class RouteTripMonitorServiceTest { ) } + @Test + fun skippingTheDestinationStillRaisesTheOvershootAlert() { + jumpPastDestination() + val arrivalAt = ReflectionHelpers.getField(service, "arrivalDetectedAtMs") + assertTrue("passing the destination must start the arrival clock", arrivalAt > 0L) + + // The service uses the JVM wall clock, not Robolectric's uptime. Age + // its recorded baseline instead of waiting for the real deadline. + val beforeDeadline = System.currentTimeMillis() - 30_000L + ReflectionHelpers.setField(service, "arrivalDetectedAtMs", beforeDeadline) + refreshAndDeliverAlerts() + assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + assertEquals( + "later overshot snapshots must not restart the clock", + beforeDeadline, + ReflectionHelpers.getField(service, "arrivalDetectedAtMs"), + ) + + ReflectionHelpers.setField(service, "arrivalDetectedAtMs", System.currentTimeMillis() - 45_000L) + refreshAndDeliverAlerts() + + assertEquals("overshot", AppRuntimeStateStore.loadPausedTripMonitor(service)?.reason) + val alert = service.getSystemService(NotificationManager::class.java) + .activeNotifications.single { it.id == 6022 }.notification + assertEquals("可能坐過站了", alert.extras.getString(Notification.EXTRA_TITLE)) + } + + @Test + fun skippingTheDestinationStillPausesAfterArrivalGraceWhenOffRoute() { + // A fix 150 m from the line cannot confirm an overshoot, but arrival's + // grace period must still finish rather than leaving the trip running. + jumpPastDestination(longitudeOffset = 0.0015) + assertTrue( + "passing the destination must start the arrival clock even off route", + ReflectionHelpers.getField(service, "arrivalDetectedAtMs") > 0L, + ) + ReflectionHelpers.setField(service, "arrivalDetectedAtMs", System.currentTimeMillis() - 60_000L) + refreshAndDeliverAlerts() + + assertEquals("arrived", AppRuntimeStateStore.loadPausedTripMonitor(service)?.reason) + } + + @Test + fun anOldStopFetchCannotOverwriteANewerAppUpdateForTheSameTrip() { + assertNewerAppStopFeedWins(stopFeed(eta = 600, plate = "OLD-001")) + } + + @Test + fun anOldStopFetchFailureCannotBackOffANewerAppUpdateForTheSameTrip() { + assertNewerAppStopFeedWins(null) + } + + private fun assertNewerAppStopFeedWins(oldResponse: String?) { + val initial = threeStopSession() + mapOf("routeId" to "TXG1", "boardingStopId" to 1) + val oldFetchStarted = CountDownLatch(1) + val releaseOldFetch = CountDownLatch(1) + backend = { path -> + if (path.endsWith("/realtime/buses")) { + busFeed("CURRENT-001", stopsOf(initial).first()) + } else { + oldFetchStarted.countDown() + check(releaseOldFetch.await(10, TimeUnit.SECONDS)) { "old stop request was never released" } + oldResponse + } + } + service.onStartCommand(startIntent(initial), 0, 1) + try { + assertTrue("the old stop request never started", oldFetchStarted.await(10, TimeUnit.SECONDS)) + val originalDetector = ReflectionHelpers.getField(service, "detector") + val currentStops = stopsOf(initial).map { stop -> + stop + mapOf("liveData" to mapOf("eta" to 0)) + } + service.onStartCommand(startIntent(initial + mapOf("stops" to currentStops)), 0, 2) + assertTrue( + "an ordinary update must keep the detector", + originalDetector === ReflectionHelpers.getField(service, "detector"), + ) + } finally { + releaseOldFetch.countDown() + } + awaitQueuedRefreshes() + refreshRide() + + val stops = ReflectionHelpers.getField>(service, "cachedLiveStops") + assertEquals("the app's arrival is newer than the pending response", 0, stops.getValue(1).seconds) + assertEquals( + "an obsolete failure must not back off the current stop feed", + 0L, + ReflectionHelpers.getField(service, "liveStopBackoffUntilMs"), + ) + assertEquals( + "an old ETA must not invent a departure after the app reported an arrival", + 0L, + ride(service).boardingCheckId, + ) + assertEquals( + "discarding the stop response must not discard valid vehicle positions", + "CURRENT-001", + ride(service).nextBusVehicleId, + ) + } + + private fun jumpPastDestination(longitudeOffset: Double = 0.0) { + ShadowSystemClock.advanceBy(10, TimeUnit.MINUTES) + val trip = threeStopSession() + mapOf("boardingStopId" to 1, "destinationStopId" to 2) + service.onStartCommand(startIntent(trip), 0, 1) + awaitQueuedRefreshes() + feedFix(stopIndex = 0, ageMinutes = 1) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + refreshAndDeliverAlerts() + assertTrue(ride(service).hasBoarded) + assertEquals(1, ride(service).remainingStops) + assertEquals(0L, ReflectionHelpers.getField(service, "arrivalDetectedAtMs")) + + // No fix at the destination: progress goes straight from before it to + // the following stop after a gap in location updates. + feedFix(stopIndex = 2, ageMinutes = 0, longitudeOffset = longitudeOffset) + refreshAndDeliverAlerts() + assertEquals( + "the fix must jump past the destination: ${ride(service)}; " + + "pause=${AppRuntimeStateStore.loadPausedTripMonitor(service)}", + 1, + ride(service).passedDestinationByStops, + ) + assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + } + + private fun refreshAndDeliverAlerts() { + fetchLiveData() + shadowOf(Looper.getMainLooper()).idle() + } + /** The other direction of [session]: the same stops, called at in the opposite order. */ private fun reversed(session: Map): Map = session + mapOf( "pathId" to 1, @@ -809,16 +941,17 @@ class RouteTripMonitorServiceTest { ) /** Delivers a fix at a stop, as the location callback does, and re-reads the ride. */ - private fun feedFix(stopIndex: Int, ageMinutes: Long) { + private fun feedFix(stopIndex: Int, ageMinutes: Long, longitudeOffset: Double = 0.0) { val (lat, lon) = THREE_STOPS[stopIndex] val fix = Location("fused").apply { latitude = lat - longitude = lon + longitude = lon + longitudeOffset accuracy = 10f elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() - TimeUnit.MINUTES.toNanos(ageMinutes) time = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(ageMinutes) } + ReflectionHelpers.setField(service, "latestLocation", fix) ReflectionHelpers.callInstanceMethod( service, "onUserLocation", From 209fd7be9625c180368d9249d71fd88059ee7ab9 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sat, 14 Nov 2026 09:14:14 +0800 Subject: [PATCH 06/11] fix(android): track family vehicles and alert on bus-only overshoots Fetch every realtime family route with per-endpoint throttling and backoff, merge each vehicle's newest report, and reject results from removed variants. Carry route validity with the actual progress source so missing or stale phone fixes cannot suppress a valid bus-only overshoot alert. Preserve ride identity through invalid reports or outages without trusting their positions, and support confirmation before vehicle reports arrive. Add service and detector regression coverage. All 140 Android unit tests pass. --- .../flutter/RouteTripMonitorService.kt | 87 ++++--- .../taiwanbus/flutter/TripRideDetector.kt | 42 +++- .../flutter/RouteTripMonitorServiceTest.kt | 233 +++++++++++++++++- .../flutter/TripRideDetectorCoMovementTest.kt | 68 +++++ 4 files changed, 385 insertions(+), 45 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index 0d762fe..fe10852 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -77,10 +77,9 @@ class RouteTripMonitorService : Service() { private var cachedLivePathId: Int? = null private var cachedLiveFetchedAtMs = 0L private var cachedLiveStops: Map = emptyMap() - private var cachedBusesRouteId: String? = null - private var cachedBusesFetchedAtMs = 0L + /** Under refreshLock: every family route shares the same polling policy. */ + private val busFeedRefreshByRoute = mutableMapOf() private var liveStopBackoffUntilMs = 0L - private var liveBusBackoffUntilMs = 0L private var wakeLock: PowerManager.WakeLock? = null private val pollingRunnable = object : Runnable { @@ -321,6 +320,11 @@ class RouteTripMonitorService : Service() { forgetLiveFeeds() } } else { + if (familyChanged) { + synchronized(refreshLock) { + busFeedRefreshByRoute.keys.retainAll(parsedSession.realtimeRouteIds.toSet()) + } + } detector?.also { it.updateDestination(parsedSession.destinationStopId) } } if (providedStops.isNotEmpty()) { @@ -370,6 +374,11 @@ class RouteTripMonitorService : Service() { val stopFeedRevision: Long, ) + private data class BusFeedRefresh( + val fetchedAtElapsedMs: Long? = null, + val retryAtElapsedMs: Long = 0L, + ) + private fun currentTrip(): TrackedTrip? = synchronized(detectorLock) { val currentSession = session ?: return null val currentDetector = detector ?: return null @@ -756,9 +765,9 @@ class RouteTripMonitorService : Service() { * * Both feeds share one rate-limit bucket with the rest of the app, so they * are throttled by age rather than fetched on every refresh, and a failed - * request backs that feed off instead of hammering the server. Each feed - * backs off on its own: the vehicle positions are what detection runs on - * when the phone has no fix, and must not go dark because the stop feed did. + * request backs that endpoint off instead of hammering the server. Every + * family route uses that same interval/back-off policy independently, so a + * failed stop feed or variant does not silence healthy vehicle endpoints. * * Returns false when [trip] was replaced while a request was out; nothing * fetched after that point has been applied, and the caller should stop. @@ -769,19 +778,19 @@ class RouteTripMonitorService : Service() { if (routeIds.isEmpty()) { return true } - val primaryRouteId = session.routeId?.trim().orEmpty() val nowElapsed = SystemClock.elapsedRealtime() - val (wantsStops, wantsBuses) = synchronized(refreshLock) { + val (wantsStops, busRouteIdsDue) = synchronized(refreshLock) { val stopsDue = nowElapsed >= liveStopBackoffUntilMs && ( cachedLiveRouteIds != routeIds || cachedLivePathId != session.pathId || nowElapsed - cachedLiveFetchedAtMs >= LIVE_STOP_REFRESH_INTERVAL_MS ) - val busesDue = primaryRouteId.isNotEmpty() && - nowElapsed >= liveBusBackoffUntilMs && ( - cachedBusesRouteId != primaryRouteId || - nowElapsed - cachedBusesFetchedAtMs >= LIVE_BUS_REFRESH_INTERVAL_MS - ) + val busesDue = routeIds.filter { routeId -> + val state = busFeedRefreshByRoute[routeId] + state == null || (nowElapsed >= state.retryAtElapsedMs && + (state.fetchedAtElapsedMs == null || + nowElapsed - state.fetchedAtElapsedMs >= LIVE_BUS_REFRESH_INTERVAL_MS)) + } stopsDue to busesDue } @@ -817,23 +826,39 @@ class RouteTripMonitorService : Service() { } } - if (wantsBuses) { - val reports = fetchBusReportsFromBackend(primaryRouteId) - return applyToTrip(trip) { - if (reports == null) { - synchronized(refreshLock) { - liveBusBackoffUntilMs = nowElapsed + LIVE_FETCH_BACKOFF_MS - } - } else { - synchronized(refreshLock) { - cachedBusesRouteId = primaryRouteId - cachedBusesFetchedAtMs = nowElapsed + val reportsByRoute = linkedMapOf>() + for (routeId in busRouteIdsDue) { + val reports = fetchBusReportsFromBackend(routeId) + if (!applyToTrip(trip) { + // A confirmed ride survives family updates, but an endpoint + // removed from its family must not publish an in-flight result. + if (routeId !in this.session?.realtimeRouteIds.orEmpty()) { + return@applyToTrip + } + synchronized(refreshLock) { + val receivedAt = SystemClock.elapsedRealtime() + busFeedRefreshByRoute[routeId] = if (reports == null) { + (busFeedRefreshByRoute[routeId] ?: BusFeedRefresh()).copy( + retryAtElapsedMs = receivedAt + LIVE_FETCH_BACKOFF_MS, + ) + } else { + BusFeedRefresh(fetchedAtElapsedMs = receivedAt) } - trip.detector.onBusReports(reports) } + reports?.let { reportsByRoute[routeId] = it } + }) { + return false } } - return true + return applyToTrip(trip) { + val currentRoutes = this.session?.realtimeRouteIds.orEmpty().toSet() + // A plate can occur in more than one endpoint; it is still one bus. + // Keep its newest report, rather than letting route iteration order + // choose which position drives a ride with no phone location. + val reports = reportsByRoute.filterKeys { it in currentRoutes }.values.flatten() + .groupBy { it.vehicleId }.values.mapNotNull { track -> track.maxByOrNull { it.gpsTimeMs } } + trip.detector.onBusReports(reports) + } } private fun feedBoardingEta( @@ -1107,6 +1132,7 @@ class RouteTripMonitorService : Service() { boardingVehicleId = ride.trackedVehicleId ?: boardingVehicleId, passedDestinationByStops = ride.passedDestinationByStops, nearestStopDistanceMeters = nearestStopDistanceMeters, + hasRouteValidProgress = ride.hasRouteValidProgress, ) } @@ -1857,10 +1883,9 @@ class RouteTripMonitorService : Service() { val destinationName = snapshot.destinationName ?: return false val passedByStops = snapshot.passedDestinationByStops ?: return false val destinationDistanceMeters = snapshot.destinationDistanceMeters ?: return false - val nearestStopDistanceMeters = snapshot.nearestStopDistanceMeters ?: return false if ( destinationDistanceMeters <= DESTINATION_OVERSHOOT_DISTANCE_METERS || - nearestStopDistanceMeters > ROUTE_PROXIMITY_MAX_DISTANCE_METERS + !snapshot.hasRouteValidProgress ) { return false } @@ -2059,10 +2084,8 @@ class RouteTripMonitorService : Service() { cachedLivePathId = null cachedLiveFetchedAtMs = 0L cachedLiveStops = emptyMap() - cachedBusesRouteId = null - cachedBusesFetchedAtMs = 0L + busFeedRefreshByRoute.clear() liveStopBackoffUntilMs = 0L - liveBusBackoffUntilMs = 0L } /** @@ -2308,7 +2331,6 @@ class RouteTripMonitorService : Service() { private const val PROGRESS_POINT_COLOR = 0x80000000.toInt() private const val OVERSHOOT_CONFIRM_DELAY_MS = 45_000L private const val ARRIVAL_AUTO_PAUSE_DELAY_MS = 60_000L - private const val ROUTE_PROXIMITY_MAX_DISTANCE_METERS = 100.0 private const val DESTINATION_OVERSHOOT_DISTANCE_METERS = 100.0 private const val DESTINATION_ARRIVED_DISTANCE_METERS = 120.0 private const val LIVE_UPDATE_SDK_INT = 36 @@ -2533,6 +2555,7 @@ data class TrackingSnapshot( val destinationDistanceMeters: Double? = null, val passedDestinationByStops: Int? = null, val nearestStopDistanceMeters: Double? = null, + val hasRouteValidProgress: Boolean = false, ) data class VehicleCandidate( diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index e29dc58..86c4121 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -62,6 +62,8 @@ internal data class RideSnapshot( val remainingStops: Int? = null, val passedDestinationByStops: Int? = null, val destinationDistanceMeters: Double? = null, + /** Validity of the same fresh phone/bus sample that supplied the progress. */ + val hasRouteValidProgress: Boolean = false, ) /** @@ -308,8 +310,10 @@ internal class TripRideDetector( markBoarded(BoardingEvidence.SELF_PROGRESS, vehicleId = null) } } - trackedVehicleId = resolveTrackedVehicle(vehicles, usableUser) - val trackedVehicle = vehicles.firstOrNull { it.vehicleId == trackedVehicleId } + trackedVehicleId = resolveTrackedVehicle(vehicles, usableUser, nowMs) + val trackedVehicle = vehicles.firstOrNull { + it.vehicleId == trackedVehicleId && it.samePath && it.onRoute + } val nextBus = if (boardingAlong == null) { null @@ -359,6 +363,16 @@ internal class TripRideDetector( remainingStops = stopsToGo?.coerceAtLeast(0), passedDestinationByStops = stopsToGo?.let { -it }?.takeIf { it > 0 }, destinationDistanceMeters = destinationDistanceMeters, + // Keep the phone's existing near-stop guard. Without a usable phone + // fix, the tracked bus's direction and route geometry are the guard; + // an unrelated cached phone location must not veto its progress. + hasRouteValidProgress = when { + usableUser != null -> line.metersToStop( + usableUser.latitude, usableUser.longitude, line.nearestStopIndex(usableUser.alongMeters), + ) <= USER_STOP_PROXIMITY_MAX_METERS + trackedVehicle != null -> trackedVehicle.samePath && trackedVehicle.onRoute + else -> false + }, ) } @@ -516,14 +530,12 @@ internal class TripRideDetector( nowMs: Long, ) { vehicles.forEach { vehicle -> - if (!vehicle.samePath) { + if (!vehicle.samePath || !vehicle.onRoute) { return@forEach } val offset = vehicle.alongMeters - boardingAlong if (offset <= AT_STOP_MARGIN_METERS) { - if (vehicle.onRoute) { - vehiclesSeenBeforeBoardingStop += vehicle.vehicleId - } + vehiclesSeenBeforeBoardingStop += vehicle.vehicleId } else if (offset > LEFT_STOP_MARGIN_METERS && vehiclesSeenBeforeBoardingStop.remove(vehicle.vehicleId) ) { @@ -641,13 +653,21 @@ internal class TripRideDetector( private fun resolveTrackedVehicle( vehicles: List, usableUser: UserSample?, + nowMs: Long, ): String? { if (!boarded) { return null } - val locked = vehicles.firstOrNull { it.vehicleId == trackedVehicleId } + val locked = vehicles.firstOrNull { it.vehicleId == trackedVehicleId && it.samePath && it.onRoute } if (usableUser == null) { - return locked?.vehicleId + // Remember the ride through an outage or an invalid report, but + // only a fresh, valid report supplies progress in snapshot(). A + // manual confirmation may also precede the first vehicle response. + return trackedVehicleId ?: if (evidence == BoardingEvidence.USER_CONFIRMED) { + vehicleJustPastBoardingStop(nowMs) + } else { + null + } } if (locked != null && usableUser.alongMeters - locked.alongMeters in -TRACK_MAX_AHEAD_METERS..TRACK_KEEP_BEHIND_METERS @@ -663,8 +683,9 @@ internal class TripRideDetector( private fun vehicleJustPastBoardingStop(nowMs: Long): String? { val boardingStop = boardingIndex - if (boardingStop < 0 || userTrack.isNotEmpty()) { - // With a location the nearest vehicle is resolved from the user. + val hasUsableUser = userTrack.lastOrNull()?.let { nowMs - it.timestampMs <= LOCATION_STALE_MS } == true + if (boardingStop < 0 || hasUsableUser) { + // With a usable location the nearest vehicle is resolved from the user. return null } val boardingAlong = line.alongOfStop(boardingStop) @@ -765,6 +786,7 @@ internal class TripRideDetector( const val MAX_USABLE_ACCURACY_METERS = 100f const val LOCATION_STALE_MS = 90_000L + const val USER_STOP_PROXIMITY_MAX_METERS = 100.0 const val MAX_PLAUSIBLE_SPEED_MPS = 55.0 const val INTERPOLATION_TOLERANCE_MS = 6_000L const val MAX_INTERPOLATION_SPAN_MS = 30_000L diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index c8a7922..ef0bb09 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -791,6 +791,225 @@ class RouteTripMonitorServiceTest { assertNewerAppStopFeedWins(null) } + @Test + fun aFamilyVariantCarriesAConfirmedRideWithoutPhoneFixes() { + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", + "familyRouteIds" to listOf("TXG1", "TXG2"), + "boardingStopId" to 1, + "destinationStopId" to 3, + ) + var busStop = stopsOf(trip).first() + var gpsTime = 1_700_000_000L + backend = { path -> + when (path) { + "/api/v1/routes/TXG1/realtime/buses" -> "[]" + "/api/v1/routes/TXG2/realtime/buses" -> + busFeed("VARIANT-001", busStop, pathId = 0, gpsTimeSeconds = gpsTime) + else -> null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + fetchLiveData() + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + refreshAndDeliverAlerts() + assertFalse(ride(service).hasUsableLocation) + assertEquals("VARIANT-001", ride(service).trackedVehicleId) + assertEquals(2, ride(service).remainingStops) + + busStop = stopsOf(trip)[1] + gpsTime += 30 + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + + assertEquals(1, ride(service).progressStopIndex) + assertEquals(1, ride(service).remainingStops) + val alert = service.getSystemService(NotificationManager::class.java) + .activeNotifications.single { it.id == 6022 }.notification + assertEquals("準備下車", alert.extras.getString(Notification.EXTRA_TITLE)) + } + + @Test + fun familyVehicleFetchesAreThrottledAndFailedRoutesBackOffIndependently() { + val requests = java.util.concurrent.CopyOnWriteArrayList() + backend = { path -> + if (path.endsWith("/realtime/buses")) { + requests += path + if (path.contains("/TXG2/")) null else "[]" + } else { + null + } + } + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", + "familyRouteIds" to listOf("TXG1", "TXG2", "TXG3", "TXG2"), + "boardingStopId" to 1, + ) + fun assertFetchCounts(primary: Int, failed: Int, healthy: Int) { + assertEquals(primary, requests.count { it.contains("/TXG1/") }) + assertEquals(failed, requests.count { it.contains("/TXG2/") }) + assertEquals(healthy, requests.count { it.contains("/TXG3/") }) + } + service.onStartCommand(startIntent(trip), 0, 1) + fetchLiveData() + assertFetchCounts(primary = 1, failed = 1, healthy = 1) + fetchLiveData() + assertFetchCounts(primary = 1, failed = 1, healthy = 1) + + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + fetchLiveData() + assertFetchCounts(primary = 2, failed = 1, healthy = 2) + + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + fetchLiveData() + assertFetchCounts(primary = 3, failed = 2, healthy = 3) + } + + @Test + fun aLateFamilyVariantIsFetchedWithoutRefetchingTheFreshPrimary() { + val requests = java.util.concurrent.CopyOnWriteArrayList() + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 3, + ) + backend = { path -> + if (path.endsWith("/realtime/buses")) { + requests += path + if (path.contains("/TXG1/")) "[]" else busFeed("VARIANT-001", stopsOf(trip).first()) + } else { + null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + fetchLiveData() + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + awaitQueuedRefreshes() + val originalDetector = ReflectionHelpers.getField(service, "detector") + service.onStartCommand( + startIntent(trip + mapOf("familyRouteIds" to listOf("TXG1", "TXG2"))), 0, 3, + ) + fetchLiveData() + + assertTrue(originalDetector === ReflectionHelpers.getField(service, "detector")) + assertTrue(ride(service).hasBoarded) + assertEquals("VARIANT-001", ride(service).trackedVehicleId) + assertEquals(1, requests.count { it.contains("/TXG1/") }) + assertEquals(1, requests.count { it.contains("/TXG2/") }) + } + + @Test + fun aRemovedFamilyVariantsInFlightResponseCannotReachTheConfirmedRide() { + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "familyRouteIds" to listOf("TXG2"), + "boardingStopId" to 1, "destinationStopId" to 3, + ) + val variantFetchStarted = CountDownLatch(1) + val releaseVariantFetch = CountDownLatch(1) + backend = { path -> + when (path) { + "/api/v1/routes/TXG1/realtime/buses" -> "[]" + "/api/v1/routes/TXG2/realtime/buses" -> { + variantFetchStarted.countDown() + check(releaseVariantFetch.await(10, TimeUnit.SECONDS)) { "variant request was never released" } + busFeed("REMOVED", stopsOf(trip)[2]) + } + else -> null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + try { + assertTrue(variantFetchStarted.await(10, TimeUnit.SECONDS)) + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + val originalDetector = ReflectionHelpers.getField(service, "detector") + service.onStartCommand(startIntent(trip + mapOf("familyRouteIds" to emptyList())), 0, 3) + assertTrue(originalDetector === ReflectionHelpers.getField(service, "detector")) + } finally { + releaseVariantFetch.countDown() + } + awaitQueuedRefreshes() + // A second confirmation would pick any leaked position at the stop. + service.onStartCommand(command("MARK_BOARDED"), 0, 4) + awaitQueuedRefreshes() + assertNull(ride(service).trackedVehicleId) + val refreshState = ReflectionHelpers.getField>(service, "busFeedRefreshByRoute") + assertFalse("an obsolete family response must not repopulate its cache", "TXG2" in refreshState) + } + + @Test + fun aTrackedBusRaisesAnOvershootAlertWithoutPhoneFixes() { + assertBusOnlyOvershoot(stalePhoneFix = false) + } + + @Test + fun aStaleFarAwayPhoneFixDoesNotHideATrackedBusOvershoot() { + assertBusOnlyOvershoot(stalePhoneFix = true) + } + + @Test + fun wrongDirectionAndOffRouteVariantsCannotBeConfirmedAsTheRidersBus() { + for ((pathId, longitudeOffset) in listOf(1 to 0.0, 0 to 0.004)) { + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "familyRouteIds" to listOf("TXG2"), + "boardingStopId" to 1, "destinationStopId" to 3, + ) + val busStop = stopsOf(trip).first() + mapOf("lon" to 120.6 + longitudeOffset) + backend = { path -> + when (path) { + "/api/v1/routes/TXG1/realtime/buses" -> "[]" + "/api/v1/routes/TXG2/realtime/buses" -> busFeed("INVALID", busStop, pathId = pathId) + else -> null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + fetchLiveData() + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + awaitQueuedRefreshes() + assertTrue(ride(service).hasBoarded) + assertNull(ride(service).trackedVehicleId) + assertEquals(-1, ride(service).progressStopIndex) + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 3) + } + } + + private fun assertBusOnlyOvershoot(stalePhoneFix: Boolean) { + ShadowSystemClock.advanceBy(10, TimeUnit.MINUTES) + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 2, + ) + var busStop = stopsOf(trip).first() + var gpsTime = 1_700_000_000L + backend = { path -> + if (path.endsWith("/realtime/buses")) { + busFeed("TRACKED-001", busStop, pathId = 0, gpsTimeSeconds = gpsTime) + } else { + null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + fetchLiveData() + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + refreshAndDeliverAlerts() + assertEquals("TRACKED-001", ride(service).trackedVehicleId) + assertEquals(0L, ReflectionHelpers.getField(service, "arrivalDetectedAtMs")) + if (stalePhoneFix) { + feedFix(stopIndex = 0, ageMinutes = 5, longitudeOffset = 0.01) + } + + busStop = stopsOf(trip)[2] + gpsTime += 30 + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + assertFalse(ride(service).hasUsableLocation) + assertEquals(1, ride(service).passedDestinationByStops) + assertTrue(ReflectionHelpers.getField(service, "arrivalDetectedAtMs") > 0L) + + ReflectionHelpers.setField(service, "arrivalDetectedAtMs", System.currentTimeMillis() - 60_000L) + refreshAndDeliverAlerts() + assertEquals("overshot", AppRuntimeStateStore.loadPausedTripMonitor(service)?.reason) + val alert = service.getSystemService(NotificationManager::class.java) + .activeNotifications.single { it.id == 6022 }.notification + assertEquals("可能坐過站了", alert.extras.getString(Notification.EXTRA_TITLE)) + } + private fun assertNewerAppStopFeedWins(oldResponse: String?) { val initial = threeStopSession() + mapOf("routeId" to "TXG1", "boardingStopId" to 1) val oldFetchStarted = CountDownLatch(1) @@ -867,6 +1086,10 @@ class RouteTripMonitorServiceTest { } private fun refreshAndDeliverAlerts() { + // Run exactly this refresh: advancing Android uptime can otherwise make + // an overdue poll race its main-thread alert with another IO snapshot. + awaitQueuedRefreshes() + ReflectionHelpers.getField(service, "mainHandler").removeCallbacksAndMessages(null) fetchLiveData() shadowOf(Looper.getMainLooper()).idle() } @@ -884,9 +1107,13 @@ class RouteTripMonitorServiceTest { private fun stopFeed(eta: Int, plate: String, stopId: Int = 1) = """{"paths":[{"stops":[{"stopid":"$stopId","eta":$eta,"etas":[{"plate":"$plate","eta":$eta}]}]}]}""" - /** One vehicle at [stop], reporting no direction, so that it counts for either. */ - private fun busFeed(vehicleId: String, stop: Map) = - """[{"id":"$vehicleId","lat":${stop["lat"]},"lon":${stop["lon"]},"time":1700000000}]""" + /** One vehicle at [stop]; direction is unknown unless supplied. */ + private fun busFeed( + vehicleId: String, + stop: Map, + pathId: Int? = null, + gpsTimeSeconds: Long = 1_700_000_000L, + ) = """[{"id":"$vehicleId","direction":$pathId,"lat":${stop["lat"]},"lon":${stop["lon"]},"time":$gpsTimeSeconds}]""" /** One refresh as the service runs it, after anything it already queued. */ private fun fetchLiveData() { diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt index 29662ab..849a6d0 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt @@ -80,6 +80,74 @@ class TripRideDetectorCoMovementTest { assertEquals(BUS, detector.snapshot(at(0)).trackedVehicleId) } + @Test + fun anInvalidReportCannotDriveAnAlreadyConfirmedRide() { + for ((pathId, offsetMeters) in listOf(1 to 0.0, 0 to 350.0)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 100.0))) + detector.confirmBoardedByUser(at(0)) + assertEquals(BUS, detector.snapshot(at(0)).trackedVehicleId) + + detector.onBusReports(listOf(report(30, along = 1_500.0, pathId = pathId, offsetMeters = offsetMeters))) + val ride = detector.snapshot(at(30)) + assertTrue(ride.hasBoarded) + assertEquals("keep the ride's identity without trusting the invalid position", BUS, ride.trackedVehicleId) + assertEquals(-1, ride.progressStopIndex) + assertFalse(ride.hasRouteValidProgress) + + detector.onBusReports(listOf(report(60, along = 1_600.0))) + val recovered = detector.snapshot(at(60)) + assertEquals(BUS, recovered.trackedVehicleId) + assertEquals(3, recovered.progressStopIndex) + assertTrue(recovered.hasRouteValidProgress) + } + } + + @Test + fun aVehicleFeedOutageDoesNotLoseTheRideOrSwitchToAnotherBus() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 100.0))) + detector.confirmBoardedByUser(at(0)) + detector.onBusReports(listOf(report(200, along = 50.0, vehicleId = "OTHER"))) + + val outage = detector.snapshot(at(200)) + assertEquals(BUS, outage.trackedVehicleId) + assertEquals(-1, outage.progressStopIndex) + assertFalse(outage.hasRouteValidProgress) + + detector.onBusReports(listOf(report(201, along = 1_600.0))) + val recovered = detector.snapshot(at(201)) + assertEquals(BUS, recovered.trackedVehicleId) + assertEquals(3, recovered.progressStopIndex) + assertTrue(recovered.hasRouteValidProgress) + } + + @Test + fun aStalePhoneFixDoesNotPreventConfirmationFromPickingTheBus() { + val detector = newDetector() + detector.onUserLocation(fix(-300, along = 0.0)) + detector.onBusReports(listOf(report(0, along = 100.0))) + detector.confirmBoardedByUser(at(0)) + + val ride = detector.snapshot(at(0)) + assertFalse(ride.hasUsableLocation) + assertEquals(BUS, ride.trackedVehicleId) + } + + @Test + fun anOffRouteReportCannotAnnounceADeparture() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + assertEquals(0L, detector.snapshot(at(0)).boardingCheckId) + detector.onBusReports(listOf(report(30, along = 600.0, offsetMeters = 350.0))) + + assertEquals( + "leaving the route is not evidence of departing the boarding stop", + 0L, + detector.snapshot(at(30)).boardingCheckId, + ) + } + /** The rider travels 300 m up the route in a minute, beside whatever [vehicle] reports. */ private fun rideAlongside(vehicle: (Long) -> RideBusReport): RideSnapshot { val detector = newDetector() From 3d607f6a99eab1907355d4686634bb300bd9e0dd Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sat, 14 Nov 2026 11:35:09 +0800 Subject: [PATCH 07/11] fix(android): require bounded evidence before identifying a boarding bus Manual confirmation preserves boarding without assigning an unrelated vehicle far along the route. Require a recent physical at-stop observation, a bounded uninterrupted route-valid track, and a short identification window; validate named departures against the same bounds. Keep ambiguous rides unidentified, preserve an already identified vehicle on repeated confirmation, and do not refresh the identification window by repeated taps. Add pure detector and service regressions for the earlier bus followed by the actual boarding bus, spatial/time boundaries, stale evidence and ambiguity. All 157 Android unit tests pass. --- .../taiwanbus/flutter/TripRideDetector.kt | 74 +++++- .../flutter/RouteTripMonitorServiceTest.kt | 57 +++++ .../flutter/TripRideDetectorCoMovementTest.kt | 6 +- .../TripRideDetectorManualBoardingTest.kt | 227 ++++++++++++++++++ 4 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index 86c4121..db53dcb 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -107,6 +107,8 @@ internal class TripRideDetector( private var boarded = false private var evidence = BoardingEvidence.NONE private var trackedVehicleId: String? = null + private var manualConfirmationAtMs: Long? = null + private var manualVehicleAmbiguous = false /** * Vehicles the rider has answered "not this one" about, each with the moment @@ -196,6 +198,8 @@ internal class TripRideDetector( } val projection = line.project(report.latitude, report.longitude, last?.alongMeters) track += BusSample( + latitude = report.latitude, + longitude = report.longitude, alongMeters = projection.alongMeters, gpsTimeMs = report.gpsTimeMs, receivedAtMs = report.receivedAtMs, @@ -235,10 +239,17 @@ internal class TripRideDetector( } fun confirmBoardedByUser(nowMs: Long) { + val identifiedVehicle = trackedVehicleId.takeIf { boarded } + if (!boarded || evidence != BoardingEvidence.USER_CONFIRMED) { + manualConfirmationAtMs = nowMs + manualVehicleAmbiguous = false + } boarded = true evidence = BoardingEvidence.USER_CONFIRMED rejectedAt.clear() - trackedVehicleId = pendingCheckVehicleId ?: vehicleJustPastBoardingStop(nowMs) + // Confirming the ride does not confirm a plate. An already identified + // vehicle remains the ride even if another bus is nearer the stop now. + trackedVehicleId = identifiedVehicle ?: identifyBoardingVehicle(nowMs) clearPendingCheck() } @@ -262,6 +273,8 @@ internal class TripRideDetector( boarded = false evidence = BoardingEvidence.NONE trackedVehicleId = null + manualConfirmationAtMs = null + manualVehicleAmbiguous = false lastDeclineAtMs = nowMs declinedDepartureAtMs = if (answeredAQuestion) pendingCheckSinceMs else nowMs declinedDepartureUnattributed = answeredAQuestion && rejected == null @@ -380,6 +393,8 @@ internal class TripRideDetector( boarded = true this.evidence = evidence trackedVehicleId = vehicleId + manualConfirmationAtMs = null + manualVehicleAmbiguous = false clearPendingCheck() } @@ -664,7 +679,7 @@ internal class TripRideDetector( // only a fresh, valid report supplies progress in snapshot(). A // manual confirmation may also precede the first vehicle response. return trackedVehicleId ?: if (evidence == BoardingEvidence.USER_CONFIRMED) { - vehicleJustPastBoardingStop(nowMs) + identifyBoardingVehicle(nowMs) } else { null } @@ -681,7 +696,21 @@ internal class TripRideDetector( ?.vehicleId } - private fun vehicleJustPastBoardingStop(nowMs: Long): String? { + /** + * Associates a manual confirmation with a vehicle, not just a route. + * + * The bus must have a recent, unbroken run on this direction and route + * containing a fix physically at the boarding stop, and still be at most + * 250 m ahead. Projection alone would clamp a bus before the first stop to + * zero progress, or put a bus on a parallel street level with the stop. + * Identification may arrive after confirmation, but only for two minutes: + * otherwise a later bus could be mistaken for the one already ridden. + */ + private fun identifyBoardingVehicle(nowMs: Long): String? { + val confirmedAt = manualConfirmationAtMs ?: return null + if (nowMs - confirmedAt !in 0L..MANUAL_IDENTIFICATION_WINDOW_MS || manualVehicleAmbiguous) { + return null + } val boardingStop = boardingIndex val hasUsableUser = userTrack.lastOrNull()?.let { nowMs - it.timestampMs <= LOCATION_STALE_MS } == true if (boardingStop < 0 || hasUsableUser) { @@ -689,10 +718,35 @@ internal class TripRideDetector( return null } val boardingAlong = line.alongOfStop(boardingStop) - return liveVehicles(nowMs) - .filter { it.samePath && it.onRoute && it.alongMeters >= boardingAlong - AT_STOP_MARGIN_METERS } - .minByOrNull { it.alongMeters } - ?.vehicleId + val candidates = liveVehicles(nowMs).filter { vehicle -> + if (!vehicle.samePath || !vehicle.onRoute || + vehicle.alongMeters - boardingAlong !in -AT_STOP_MARGIN_METERS..MANUAL_VEHICLE_MAX_AHEAD_METERS + ) { + return@filter false + } + val track = busTracks[vehicle.vehicleId] ?: return@filter false + val latest = track.lastOrNull() ?: return@filter false + if (nowMs - latest.receivedAtMs !in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS) { + return@filter false + } + track.reversed().takeWhile { it.samePath && it.onRoute }.any { sample -> + nowMs - sample.receivedAtMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS && + abs(sample.alongMeters - boardingAlong) <= AT_STOP_MARGIN_METERS && + line.metersToStop(sample.latitude, sample.longitude, boardingStop) <= + MANUAL_AT_STOP_RADIUS_METERS + } + } + val namedDeparture = pendingCheckVehicleId.takeIf { + nowMs - pendingCheckSinceMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS + } + candidates.firstOrNull { it.vehicleId == namedDeparture }?.let { return it.vehicleId } + if (candidates.size > 1) { + // One candidate later leaving the distance bound does not prove + // that the rider took the other one. A usable phone fix can still + // identify the ride through resolveTrackedVehicle(). + manualVehicleAmbiguous = true + } + return candidates.singleOrNull()?.vehicleId } private fun liveVehicles(nowMs: Long): List { @@ -758,6 +812,8 @@ internal class TripRideDetector( ) private data class BusSample( + val latitude: Double, + val longitude: Double, val alongMeters: Double, val gpsTimeMs: Long, val receivedAtMs: Long, @@ -816,6 +872,10 @@ internal class TripRideDetector( const val ON_ROUTE_SEGMENT_SHARE = 0.6 const val AT_STOP_MARGIN_METERS = 40.0 const val LEFT_STOP_MARGIN_METERS = 120.0 + const val MANUAL_AT_STOP_RADIUS_METERS = 80.0 + const val MANUAL_VEHICLE_MAX_AHEAD_METERS = 250.0 + const val MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS = 90_000L + const val MANUAL_IDENTIFICATION_WINDOW_MS = 120_000L const val ETA_ARRIVING_SECONDS = 30 const val ETA_ROLLOVER_SECONDS = 240 diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index ef0bb09..b198f3a 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -944,6 +944,63 @@ class RouteTripMonitorServiceTest { assertBusOnlyOvershoot(stalePhoneFix = true) } + @Test + fun manualBoardingWaitsForTheActualBusInsteadOfAnEarlierBusPastTheDestination() { + val stops = (0..5).map { index -> + mapOf( + "stopId" to index + 1, "stopName" to "stop-$index", "sequence" to index + 1, + "lat" to 24.1 + index * 0.0045, "lon" to 120.6, + ) + } + val trip = sessionPayload() + mapOf( + "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 4, "stops" to stops, + ) + var actualStop: Map? = null + var gpsTime = 1_700_000_000L + backend = { path -> + if (path.endsWith("/realtime/buses")) { + val reports = org.json.JSONArray(busFeed("EARLIER", stops[5], pathId = 0, gpsTimeSeconds = gpsTime)) + actualStop?.let { stop -> + reports.put(org.json.JSONArray(busFeed("ACTUAL", stop, pathId = 0, gpsTimeSeconds = gpsTime)) + .getJSONObject(0)) + } + reports.toString() + } else { + null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + fetchLiveData() + service.onStartCommand(command("MARK_BOARDED"), 0, 2) + refreshAndDeliverAlerts() + + assertTrue(ride(service).hasBoarded) + assertNull(ride(service).trackedVehicleId) + assertNull(ride(service).passedDestinationByStops) + assertEquals(0L, ReflectionHelpers.getField(service, "arrivalDetectedAtMs")) + assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + + actualStop = stops.first() + gpsTime += 30 + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + assertEquals("ACTUAL", ride(service).trackedVehicleId) + assertEquals(0, ride(service).progressStopIndex) + + actualStop = stops[1] + gpsTime += 30 + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + assertEquals("ACTUAL", ride(service).trackedVehicleId) + assertEquals(2, ride(service).remainingStops) + assertNull(ride(service).passedDestinationByStops) + assertEquals(0L, ReflectionHelpers.getField(service, "arrivalDetectedAtMs")) + assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + val alert = service.getSystemService(NotificationManager::class.java) + .activeNotifications.single { it.id == 6022 }.notification + assertEquals("1 快到了", alert.extras.getString(Notification.EXTRA_TITLE)) + } + @Test fun wrongDirectionAndOffRouteVariantsCannotBeConfirmedAsTheRidersBus() { for ((pathId, longitudeOffset) in listOf(1 to 0.0, 0 to 0.004)) { diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt index 849a6d0..1afe8c6 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorCoMovementTest.kt @@ -84,7 +84,7 @@ class TripRideDetectorCoMovementTest { fun anInvalidReportCannotDriveAnAlreadyConfirmedRide() { for ((pathId, offsetMeters) in listOf(1 to 0.0, 0 to 350.0)) { val detector = newDetector() - detector.onBusReports(listOf(report(0, along = 100.0))) + detector.onBusReports(listOf(report(0, along = 0.0))) detector.confirmBoardedByUser(at(0)) assertEquals(BUS, detector.snapshot(at(0)).trackedVehicleId) @@ -106,7 +106,7 @@ class TripRideDetectorCoMovementTest { @Test fun aVehicleFeedOutageDoesNotLoseTheRideOrSwitchToAnotherBus() { val detector = newDetector() - detector.onBusReports(listOf(report(0, along = 100.0))) + detector.onBusReports(listOf(report(0, along = 0.0))) detector.confirmBoardedByUser(at(0)) detector.onBusReports(listOf(report(200, along = 50.0, vehicleId = "OTHER"))) @@ -126,7 +126,7 @@ class TripRideDetectorCoMovementTest { fun aStalePhoneFixDoesNotPreventConfirmationFromPickingTheBus() { val detector = newDetector() detector.onUserLocation(fix(-300, along = 0.0)) - detector.onBusReports(listOf(report(0, along = 100.0))) + detector.onBusReports(listOf(report(0, along = 0.0))) detector.confirmBoardedByUser(at(0)) val ride = detector.snapshot(at(0)) diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt new file mode 100644 index 0000000..bcc7cdd --- /dev/null +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt @@ -0,0 +1,227 @@ +package tw.avianjay.taiwanbus.flutter + +import kotlin.math.PI +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Confirming a ride says nothing about which of the route's vehicles it is. */ +class TripRideDetectorManualBoardingTest { + @Test + fun manualConfirmationDoesNotChooseAnEarlierBusSeveralStopsAhead() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 2_500.0, id = "AHEAD-ONLY"))) + detector.confirmBoardedByUser(at(0)) + + assertUnidentified(detector.snapshot(at(0))) + } + + @Test + fun anUnknownRideWaitsForItsActualBoardingBus() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 2_500.0, id = "EARLIER"))) + detector.confirmBoardedByUser(at(0)) + assertUnidentified(detector.snapshot(at(0))) + + detector.onBusReports(listOf( + report(20, along = 2_500.0, id = "EARLIER"), + report(20, along = 0.0), + )) + assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) + + // Identification is bounded; travel on the identified vehicle is not. + detector.onBusReports(listOf(report(180, along = 2_000.0))) + val ride = detector.snapshot(at(180)) + assertEquals(BUS, ride.trackedVehicleId) + assertEquals(1, ride.passedDestinationByStops) + assertTrue(ride.hasRouteValidProgress) + } + + @Test + fun aNearbyVehicleNeedsAnActualAtStopObservation() { + for (along in listOf(100.0, 200.0)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = along))) + detector.confirmBoardedByUser(at(0)) + + assertUnidentified(detector.snapshot(at(0))) + } + } + + @Test + fun aVehicleBeforeTheFirstStopIsNotAtTheBoardingStop() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = -200.0))) + detector.confirmBoardedByUser(at(0)) + + assertUnidentified(detector.snapshot(at(0))) + } + + @Test + fun aVehicleBesideTheStopIsNotAnAtStopObservation() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0).copy(longitude = 120.6015))) + detector.confirmBoardedByUser(at(0)) + + assertUnidentified(detector.snapshot(at(0))) + } + + @Test + fun aRecentBoundedDepartureCanBeIdentified() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0), report(20, along = 200.0))) + detector.confirmBoardedByUser(at(20)) + + assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) + } + + @Test + fun anIdentifiedDepartureStillHasAnUpperDistanceBound() { + for (along in listOf(249.0, 251.0)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = along))) + assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + detector.confirmBoardedByUser(at(20)) + + val expected = BUS.takeIf { along < 250.0 } + assertEquals( + "a pending question cannot bypass the distance bound", + expected, + detector.snapshot(at(20)).trackedVehicleId, + ) + } + } + + @Test + fun atStopEvidenceExpiresAfterNinetySeconds() { + for (seconds in listOf(90L, 91L)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(seconds, along = 200.0))) + detector.snapshot(at(seconds)) + detector.confirmBoardedByUser(at(seconds)) + + assertEquals(BUS.takeIf { seconds == 90L }, detector.snapshot(at(seconds)).trackedVehicleId) + } + } + + @Test + fun repeatingTheSameFeedFixDoesNotRefreshItsEvidence() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.onBusReports(listOf(report(0, along = 0.0).copy(receivedAtMs = at(100)))) + detector.confirmBoardedByUser(at(100)) + + assertUnidentified(detector.snapshot(at(100))) + } + + @Test + fun lateIdentificationExpiresAndRepeatedConfirmationDoesNotRearmIt() { + for (seconds in listOf(120L, 121L)) { + val detector = newDetector() + detector.confirmBoardedByUser(at(0)) + detector.onBusReports(listOf(report(seconds, along = 0.0))) + val expected = BUS.takeIf { seconds == 120L } + assertEquals(expected, detector.snapshot(at(seconds)).trackedVehicleId) + detector.confirmBoardedByUser(at(seconds + 1)) + + assertEquals(expected, detector.snapshot(at(seconds + 1)).trackedVehicleId) + } + } + + @Test + fun anInvalidStretchBreaksTheAtStopEvidence() { + for ((pathId, longitude) in listOf(1 to 120.6, 0 to 120.61)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.onBusReports(listOf(report(10, along = 100.0).copy(pathId = pathId, longitude = longitude))) + detector.onBusReports(listOf(report(20, along = 200.0))) + detector.confirmBoardedByUser(at(20)) + + assertUnidentified(detector.snapshot(at(20))) + } + } + + @Test + fun ambiguousNearbyVehiclesDoNotBecomeIdentifiedJustBecauseOneLeavesTheBound() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0), report(0, along = 0.0, id = "OTHER"))) + detector.confirmBoardedByUser(at(0)) + assertUnidentified(detector.snapshot(at(0))) + detector.onBusReports(listOf(report(20, along = 500.0), report(20, along = 0.0, id = "OTHER"))) + + assertUnidentified(detector.snapshot(at(20))) + detector.onUserLocation(RideGeoPoint(latitude(500.0), 120.6, at(20), accuracyMeters = 10f)) + assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) + } + + @Test + fun confirmingAnIdentifiedRideDoesNotSwitchToANearerVehicle() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.confirmBoardedByUser(at(0)) + assertEquals(BUS, detector.snapshot(at(0)).trackedVehicleId) + detector.onBusReports(listOf(report(60, along = 1_500.0), report(60, along = 0.0, id = "OTHER"))) + detector.confirmBoardedByUser(at(60)) + + assertEquals(BUS, detector.snapshot(at(60)).trackedVehicleId) + } + + @Test + fun aValidNamedDepartureCanDisambiguateNearbyVehicles() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0), report(20, along = 0.0, id = "OTHER"))) + assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + detector.confirmBoardedByUser(at(20)) + + assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) + } + + @Test + fun anExpiredDepartureQuestionCannotDisambiguateFreshVehicles() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0))) + assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + detector.onBusReports(listOf(report(111, along = 0.0), report(111, along = 0.0, id = "OTHER"))) + detector.confirmBoardedByUser(at(111)) + + assertUnidentified(detector.snapshot(at(111))) + } + + private fun assertUnidentified(ride: RideSnapshot) { + assertTrue("the manual action still confirms that the rider boarded", ride.hasBoarded) + assertEquals(BoardingEvidence.USER_CONFIRMED, ride.evidence) + assertNull("confirming a ride does not identify an unrelated vehicle", ride.trackedVehicleId) + assertEquals(-1, ride.progressStopIndex) + assertNull(ride.passedDestinationByStops) + assertFalse(ride.hasRouteValidProgress) + } + + private fun newDetector() = TripRideDetector( + stops = (0..5).map { index -> + TrackingStop(index + 1, "stop-$index", index, latitude(index * 500.0), 120.6) + }, + pathId = 0, boardingStopId = 1, destinationStopId = 4, + ) + + private fun report(seconds: Long, along: Double, id: String = BUS) = RideBusReport( + id, 0, latitude(along), 120.6, gpsTimeMs = at(seconds), receivedAtMs = at(seconds), + ) + + private fun latitude(meters: Double) = 24.1 + meters / (6_371_000.0 * PI / 180.0) + + private fun at(seconds: Long) = 1_700_000_000_000L + seconds * 1_000L + + private companion object { + const val BUS = "ACTUAL" + } +} From 5665a40f989400bd3d17775cdb27d5e14087672d Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sat, 14 Nov 2026 14:45:10 +0800 Subject: [PATCH 08/11] fix(android): tie a confirmed ride to the bus it was confirmed on The review's finding, and what a pass over the same code turned up. - Confirming by hand could adopt a bus that called at the boarding stop afterwards. The at-stop fix that identifies a vehicle must now have been made, by the vehicle's clock, in the ninety seconds up to the confirmation; the report carrying it may still arrive later. - Answering the question about a named departure follows that bus. It used to be held to the 250 m bound, which a bus has nearly always passed by the time the question is shown, so the answer named nothing. A question that has lapsed names nothing either. - A vehicle last seen short of the stop and next seen past it more than three minutes later did not just leave: no departure, no question. - A ride picked back up after choosing a destination was confirmed again at that moment, opening identification at a stop the rider had left minutes before. It now resumes with the vehicle unknown until the rider's own position finds it. - latestLocation and appInForeground are read off the main thread and are now volatile. Service tests stamp vehicle reports with the current time, as the feed does. All 161 Android unit tests pass; each fix fails its test when reverted. --- .../flutter/RouteTripMonitorService.kt | 7 +- .../taiwanbus/flutter/TripRideDetector.kt | 68 +++++++--- .../flutter/RouteTripMonitorServiceTest.kt | 46 +++++-- .../TripRideDetectorManualBoardingTest.kt | 120 ++++++++++++++---- 4 files changed, 183 insertions(+), 58 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index fe10852..395dddc 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -48,8 +48,10 @@ class RouteTripMonitorService : Service() { @Volatile private var session: TrackingSession? = null + @Volatile private var appInForeground = true private var foregroundStarted = false + @Volatile private var latestLocation: Location? = null /** @@ -309,8 +311,9 @@ class RouteTripMonitorService : Service() { createDetector(parsedSession).also { created -> if (continuesBoardedRide) { // Choosing where to get off, in answer to "you - // have boarded", is the rider confirming it. - created.confirmBoardedByUser(startedAtMs) + // have boarded", is the rider confirming it — + // but of a ride that began some stops back. + created.resumeConfirmedRide() } detector = created latestRide = RideSnapshot() diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index db53dcb..4e0f403 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -97,7 +97,8 @@ internal class TripRideDetector( private val userTrack = ArrayDeque() private val busTracks = LinkedHashMap>() - private val vehiclesSeenBeforeBoardingStop = HashSet() + /** Vehicles last seen short of the boarding stop, with when that report arrived. */ + private val seenBeforeBoardingStopAt = HashMap() private val explicitBoardingIndex = indexOfStop(boardingStopId) private var anchoredBoardingIndex = -1 @@ -240,6 +241,13 @@ internal class TripRideDetector( fun confirmBoardedByUser(nowMs: Long) { val identifiedVehicle = trackedVehicleId.takeIf { boarded } + // A question that names a bus is about that bus: it was watched coming + // up to the stop and leaving it, and "yes" is the rider's answer to + // exactly that. Nothing seen afterwards can be tied to the ride as + // firmly, however near the stop it is. + val askedAbout = pendingCheckVehicleId.takeIf { + pendingCheckSinceMs != 0L && nowMs - pendingCheckSinceMs in 0L..BOARDING_CHECK_MAX_AGE_MS + } if (!boarded || evidence != BoardingEvidence.USER_CONFIRMED) { manualConfirmationAtMs = nowMs manualVehicleAmbiguous = false @@ -249,7 +257,23 @@ internal class TripRideDetector( rejectedAt.clear() // Confirming the ride does not confirm a plate. An already identified // vehicle remains the ride even if another bus is nearer the stop now. - trackedVehicleId = identifiedVehicle ?: identifyBoardingVehicle(nowMs) + trackedVehicleId = identifiedVehicle ?: askedAbout ?: identifyBoardingVehicle(nowMs) + clearPendingCheck() + } + + /** + * Takes up a ride the rider confirmed earlier, on a detector built since. + * + * The confirmation was given some way back along the route, so nothing at + * the boarding stop now has anything to do with it: the vehicle stays + * unknown until the rider's own position picks it out. + */ + fun resumeConfirmedRide() { + boarded = true + evidence = BoardingEvidence.USER_CONFIRMED + trackedVehicleId = null + manualConfirmationAtMs = null + manualVehicleAmbiguous = false clearPendingCheck() } @@ -550,11 +574,14 @@ internal class TripRideDetector( } val offset = vehicle.alongMeters - boardingAlong if (offset <= AT_STOP_MARGIN_METERS) { - vehiclesSeenBeforeBoardingStop += vehicle.vehicleId - } else if (offset > LEFT_STOP_MARGIN_METERS && - vehiclesSeenBeforeBoardingStop.remove(vehicle.vehicleId) - ) { - registerDeparture(vehicle.vehicleId, nowMs) + seenBeforeBoardingStopAt[vehicle.vehicleId] = vehicle.receivedAtMs + } else if (offset > LEFT_STOP_MARGIN_METERS) { + val seenAt = seenBeforeBoardingStopAt.remove(vehicle.vehicleId) ?: return@forEach + // A vehicle that went quiet short of the stop and turns up past + // it much later left long ago; nobody is boarding it now. + if (vehicle.receivedAtMs - seenAt <= DEPARTURE_MAX_REPORT_GAP_MS) { + registerDeparture(vehicle.vehicleId, nowMs) + } } } } @@ -699,12 +726,16 @@ internal class TripRideDetector( /** * Associates a manual confirmation with a vehicle, not just a route. * - * The bus must have a recent, unbroken run on this direction and route - * containing a fix physically at the boarding stop, and still be at most - * 250 m ahead. Projection alone would clamp a bus before the first stop to - * zero progress, or put a bus on a parallel street level with the stop. - * Identification may arrive after confirmation, but only for two minutes: - * otherwise a later bus could be mistaken for the one already ridden. + * The bus must have an unbroken run on this direction and route containing + * a fix physically at the boarding stop, and still be at most 250 m ahead. + * Projection alone would clamp a bus before the first stop to zero + * progress, or put a bus on a parallel street level with the stop. + * + * That fix must have been made in the minute and a half up to the moment + * the rider confirmed, by the vehicle's own clock. The report carrying it + * can reach the app afterwards — the feed runs half a minute behind — which + * is what the two-minute window is for. A bus that pulls in after the + * rider said they were already on one is the next bus, whenever it is seen. */ private fun identifyBoardingVehicle(nowMs: Long): String? { val confirmedAt = manualConfirmationAtMs ?: return null @@ -730,16 +761,12 @@ internal class TripRideDetector( return@filter false } track.reversed().takeWhile { it.samePath && it.onRoute }.any { sample -> - nowMs - sample.receivedAtMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS && + confirmedAt - sample.gpsTimeMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS && abs(sample.alongMeters - boardingAlong) <= AT_STOP_MARGIN_METERS && line.metersToStop(sample.latitude, sample.longitude, boardingStop) <= MANUAL_AT_STOP_RADIUS_METERS } } - val namedDeparture = pendingCheckVehicleId.takeIf { - nowMs - pendingCheckSinceMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS - } - candidates.firstOrNull { it.vehicleId == namedDeparture }?.let { return it.vehicleId } if (candidates.size > 1) { // One candidate later leaving the distance bound does not prove // that the rider took the other one. A usable phone fix can still @@ -755,7 +782,7 @@ internal class TripRideDetector( if (nowMs - last.receivedAtMs > BUS_REPORT_STALE_MS) { return@mapNotNull null } - LiveVehicle(vehicleId, last.alongMeters, last.samePath, last.onRoute) + LiveVehicle(vehicleId, last.alongMeters, last.samePath, last.onRoute, last.receivedAtMs) } } @@ -796,6 +823,7 @@ internal class TripRideDetector( } busTracks.entries.removeAll { it.value.isEmpty() } rejectedAt.entries.removeAll { nowMs - it.value > REJECTED_VEHICLE_TTL_MS } + seenBeforeBoardingStopAt.keys.retainAll(busTracks.keys) } private fun indexOfStop(stopId: Int?): Int { @@ -826,6 +854,7 @@ internal class TripRideDetector( val alongMeters: Double, val samePath: Boolean, val onRoute: Boolean, + val receivedAtMs: Long, ) private data class CoMovementPair( @@ -872,6 +901,7 @@ internal class TripRideDetector( const val ON_ROUTE_SEGMENT_SHARE = 0.6 const val AT_STOP_MARGIN_METERS = 40.0 const val LEFT_STOP_MARGIN_METERS = 120.0 + const val DEPARTURE_MAX_REPORT_GAP_MS = 180_000L const val MANUAL_AT_STOP_RADIUS_METERS = 80.0 const val MANUAL_VEHICLE_MAX_AHEAD_METERS = 250.0 const val MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS = 90_000L diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index b198f3a..cad20bc 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -403,13 +403,14 @@ class RouteTripMonitorServiceTest { @Test fun boardingWithoutADestinationAsksForOneAndPicksTheRideBackUp() { - val withBoardingStop = threeStopSession() + mapOf( + val route = threeStopSession() + mapOf("routeId" to "TXG1") + val withBoardingStop = route + mapOf( "boardingStopId" to 1, "boardingStopName" to "一站", ) // With and without a boarding stop chosen: both used to be reported as // "still waiting" however the ride had been confirmed. - for (waiting in listOf(withBoardingStop, threeStopSession())) { + for (waiting in listOf(withBoardingStop, route)) { service.onStartCommand(startIntent(waiting), 0, 1) service.onStartCommand(command("MARK_BOARDED"), 0, 2) assertTrue(ride(service).hasBoarded) @@ -422,7 +423,17 @@ class RouteTripMonitorServiceTest { assertNotNull("the rider must be asked where they get off", prompt) assertEquals("已偵測上車", prompt.extras.getString(Notification.EXTRA_TITLE)) - // The rider answers by choosing a stop. + // The rider answers by choosing a stop. By now they are stops away, + // and the bus that called at the boarding stop half a minute ago + // is someone else's. + val calledAt = nowSeconds() - 30 + backend = { path -> + if (path.endsWith("/realtime/buses")) { + busFeed("NEXT-BUS", stopsOf(waiting).first(), pathId = 0, gpsTimeSeconds = calledAt) + } else { + null + } + } service.onStartCommand( startIntent(waiting + mapOf("destinationStopId" to 3, "destinationStopName" to "三站")), 0, @@ -433,6 +444,14 @@ class RouteTripMonitorServiceTest { assertEquals(BoardingEvidence.USER_CONFIRMED, ride(service).evidence) assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + fetchLiveData() + assertTrue(ride(service).hasBoarded) + assertNull( + "a bus at the boarding stop now is not the one boarded back then", + ride(service).trackedVehicleId, + ) + backend = { null } + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 4) } } @@ -800,7 +819,7 @@ class RouteTripMonitorServiceTest { "destinationStopId" to 3, ) var busStop = stopsOf(trip).first() - var gpsTime = 1_700_000_000L + var gpsTime = nowSeconds() backend = { path -> when (path) { "/api/v1/routes/TXG1/realtime/buses" -> "[]" @@ -956,13 +975,16 @@ class RouteTripMonitorServiceTest { "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 4, "stops" to stops, ) var actualStop: Map? = null - var gpsTime = 1_700_000_000L + var gpsTime = nowSeconds() + var actualGpsTime = gpsTime backend = { path -> if (path.endsWith("/realtime/buses")) { val reports = org.json.JSONArray(busFeed("EARLIER", stops[5], pathId = 0, gpsTimeSeconds = gpsTime)) actualStop?.let { stop -> - reports.put(org.json.JSONArray(busFeed("ACTUAL", stop, pathId = 0, gpsTimeSeconds = gpsTime)) - .getJSONObject(0)) + reports.put( + org.json.JSONArray(busFeed("ACTUAL", stop, pathId = 0, gpsTimeSeconds = actualGpsTime)) + .getJSONObject(0), + ) } reports.toString() } else { @@ -980,6 +1002,8 @@ class RouteTripMonitorServiceTest { assertEquals(0L, ReflectionHelpers.getField(service, "arrivalDetectedAtMs")) assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + // The feed catches up half a minute later, with the fix the rider's + // bus made at the stop just before they confirmed. actualStop = stops.first() gpsTime += 30 ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) @@ -989,6 +1013,7 @@ class RouteTripMonitorServiceTest { actualStop = stops[1] gpsTime += 30 + actualGpsTime = gpsTime ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) refreshAndDeliverAlerts() assertEquals("ACTUAL", ride(service).trackedVehicleId) @@ -1033,7 +1058,7 @@ class RouteTripMonitorServiceTest { "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 2, ) var busStop = stopsOf(trip).first() - var gpsTime = 1_700_000_000L + var gpsTime = nowSeconds() backend = { path -> if (path.endsWith("/realtime/buses")) { busFeed("TRACKED-001", busStop, pathId = 0, gpsTimeSeconds = gpsTime) @@ -1165,11 +1190,14 @@ class RouteTripMonitorServiceTest { """{"paths":[{"stops":[{"stopid":"$stopId","eta":$eta,"etas":[{"plate":"$plate","eta":$eta}]}]}]}""" /** One vehicle at [stop]; direction is unknown unless supplied. */ + /** Vehicles stamp their reports with the same clock the phone runs on. */ + private fun nowSeconds() = System.currentTimeMillis() / 1000 + private fun busFeed( vehicleId: String, stop: Map, pathId: Int? = null, - gpsTimeSeconds: Long = 1_700_000_000L, + gpsTimeSeconds: Long = nowSeconds(), ) = """[{"id":"$vehicleId","direction":$pathId,"lat":${stop["lat"]},"lon":${stop["lon"]},"time":$gpsTimeSeconds}]""" /** One refresh as the service runs it, after anything it already queued. */ diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt index bcc7cdd..53192d4 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt @@ -25,9 +25,11 @@ class TripRideDetectorManualBoardingTest { detector.confirmBoardedByUser(at(0)) assertUnidentified(detector.snapshot(at(0))) + // The feed runs behind: the fix the rider's bus made at the stop, just + // before they confirmed, only reaches the app now. detector.onBusReports(listOf( report(20, along = 2_500.0, id = "EARLIER"), - report(20, along = 0.0), + report(-5, along = 0.0).copy(receivedAtMs = at(20)), )) assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) @@ -78,32 +80,106 @@ class TripRideDetectorManualBoardingTest { } @Test - fun anIdentifiedDepartureStillHasAnUpperDistanceBound() { - for (along in listOf(249.0, 251.0)) { + fun aLaterBusCallingAtTheStopIsNotTheConfirmedRide() { + // The rider's own bus has no position feed. Whatever pulls in after + // they said they were on board is the next bus. + for (arrivesAfterSeconds in listOf(1L, 20L, 60L, 110L)) { + val detector = newDetector() + detector.confirmBoardedByUser(at(0)) + assertUnidentified(detector.snapshot(at(0))) + + detector.onBusReports(listOf(report(arrivesAfterSeconds, along = 0.0, id = "LATER-BUS"))) + assertUnidentified(detector.snapshot(at(arrivesAfterSeconds))) + + // It runs on past the rider's destination; the ride is not it. + detector.onBusReports(listOf(report(arrivesAfterSeconds + 60, along = 2_000.0, id = "LATER-BUS"))) + assertUnidentified(detector.snapshot(at(arrivesAfterSeconds + 60))) + } + } + + @Test + fun aLaterBusIsNotTakenEvenAfterTheRidersOwnBusWasSeenLeaving() { + val detector = newDetector() + detector.onBusReports(listOf(report(-10, along = 0.0))) + detector.confirmBoardedByUser(at(0)) + assertEquals(BUS, detector.snapshot(at(0)).trackedVehicleId) + + detector.onBusReports(listOf(report(60, along = 600.0), report(60, along = 0.0, id = "LATER-BUS"))) + assertEquals(BUS, detector.snapshot(at(60)).trackedVehicleId) + } + + @Test + fun answeringTheQuestionAboutANamedBusFollowsThatBus() { + // The question comes up 40 s after the bus has left, by which time it + // is well down the road; "yes" is still about that bus. + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0))) + assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + detector.onBusReports(listOf(report(60, along = 700.0), report(60, along = 0.0, id = "OTHER"))) + assertTrue(detector.snapshot(at(60)).boardingPromptDue) + detector.confirmBoardedByUser(at(70)) + + val ride = detector.snapshot(at(70)) + assertEquals(BUS, ride.trackedVehicleId) + assertEquals(1, ride.progressStopIndex) + assertTrue(ride.hasRouteValidProgress) + } + + @Test + fun aQuestionThatHasLapsedNamesNoBus() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0))) + assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + detector.onBusReports(listOf(report(330, along = 2_400.0))) + // Not snapshotted in between, so the question was never retired. + detector.confirmBoardedByUser(at(330)) + + assertUnidentified(detector.snapshot(at(330))) + } + + @Test + fun aVehicleBackAfterALongSilenceDidNotJustLeaveTheStop() { + for ((silenceSeconds, asked) in listOf(180L to true, 181L to false)) { val detector = newDetector() detector.onBusReports(listOf(report(0, along = 0.0))) - detector.snapshot(at(0)) - detector.onBusReports(listOf(report(20, along = along))) - assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) - detector.confirmBoardedByUser(at(20)) + assertEquals(0L, detector.snapshot(at(0)).boardingCheckId) + detector.onBusReports(listOf(report(silenceSeconds, along = 2_400.0))) - val expected = BUS.takeIf { along < 250.0 } assertEquals( - "a pending question cannot bypass the distance bound", - expected, - detector.snapshot(at(20)).trackedVehicleId, + "a bus last seen at the stop $silenceSeconds s ago", + asked, + detector.snapshot(at(silenceSeconds)).boardingCheckId != 0L, ) } } @Test - fun atStopEvidenceExpiresAfterNinetySeconds() { + fun aRidePickedBackUpDoesNotAdoptTheBusNowAtTheBoardingStop() { + val detector = newDetector() + detector.onBusReports(listOf(report(-10, along = 0.0, id = "NEXT-BUS"))) + detector.resumeConfirmedRide() + + assertUnidentified(detector.snapshot(at(0))) + detector.onBusReports(listOf(report(20, along = 100.0, id = "NEXT-BUS"))) + assertUnidentified(detector.snapshot(at(20))) + + // The rider's own position is what finds their bus again. + detector.onBusReports(listOf(report(40, along = 1_400.0))) + detector.onUserLocation(RideGeoPoint(latitude(1_500.0), 120.6, at(40), accuracyMeters = 10f)) + assertEquals(BUS, detector.snapshot(at(40)).trackedVehicleId) + } + + @Test + fun atStopEvidenceMustBeFromTheNinetySecondsBeforeConfirming() { for (seconds in listOf(90L, 91L)) { val detector = newDetector() detector.onBusReports(listOf(report(0, along = 0.0))) detector.snapshot(at(0)) - detector.onBusReports(listOf(report(seconds, along = 200.0))) - detector.snapshot(at(seconds)) + detector.onBusReports(listOf(report(seconds - 5, along = 100.0))) detector.confirmBoardedByUser(at(seconds)) assertEquals(BUS.takeIf { seconds == 90L }, detector.snapshot(at(seconds)).trackedVehicleId) @@ -125,7 +201,8 @@ class TripRideDetectorManualBoardingTest { for (seconds in listOf(120L, 121L)) { val detector = newDetector() detector.confirmBoardedByUser(at(0)) - detector.onBusReports(listOf(report(seconds, along = 0.0))) + // Made at the stop before the rider confirmed, delivered late. + detector.onBusReports(listOf(report(-10, along = 0.0).copy(receivedAtMs = at(seconds)))) val expected = BUS.takeIf { seconds == 120L } assertEquals(expected, detector.snapshot(at(seconds)).trackedVehicleId) detector.confirmBoardedByUser(at(seconds + 1)) @@ -184,19 +261,6 @@ class TripRideDetectorManualBoardingTest { assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) } - @Test - fun anExpiredDepartureQuestionCannotDisambiguateFreshVehicles() { - val detector = newDetector() - detector.onBusReports(listOf(report(0, along = 0.0))) - detector.snapshot(at(0)) - detector.onBusReports(listOf(report(20, along = 150.0))) - assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) - detector.onBusReports(listOf(report(111, along = 0.0), report(111, along = 0.0, id = "OTHER"))) - detector.confirmBoardedByUser(at(111)) - - assertUnidentified(detector.snapshot(at(111))) - } - private fun assertUnidentified(ride: RideSnapshot) { assertTrue("the manual action still confirms that the rider boarded", ride.hasBoarded) assertEquals(BoardingEvidence.USER_CONFIRMED, ride.evidence) From 4fb8c820e9d0100ca8ef56da532bfde5e680c399 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sat, 14 Nov 2026 15:34:23 +0800 Subject: [PATCH 09/11] fix(android): only an answer to the boarding question names its bus "I have boarded" is always on offer on the tracking notification, and a question about a bus that just left can be pending for 40 s before it is shown. Confirming unprompted in that window adopted the pending bus, though the rider had not been asked about it and could be on another. - The boarding question's own buttons now carry the id of the question they answer, on PendingIntents of their own; the tracking notification's button carries none. The named bus is adopted only when the answer is to the question still pending. - Unprompted, a bus already seen pulling away before the rider confirmed is not adopted from its earlier fix at the stop either: whether they were on it is what the question is for. A bus still standing at the stop, as far as the feed has said, still is. - "Not yet" to a question since replaced by another no longer sets aside the bus the new question is about. All 166 Android unit tests pass; each change fails a test when reverted. --- .../flutter/RouteTripMonitorService.kt | 50 +++++++---- .../taiwanbus/flutter/TripRideDetector.kt | 43 +++++++-- .../flutter/RouteTripMonitorServiceTest.kt | 66 ++++++++++++++ .../TripRideDetectorManualBoardingTest.kt | 89 ++++++++++++++++--- 4 files changed, 213 insertions(+), 35 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index 395dddc..9f5a403 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -196,7 +196,7 @@ class RouteTripMonitorService : Service() { } val now = System.currentTimeMillis() synchronized(detectorLock) { - detector?.confirmBoardedByUser(now) + detector?.confirmBoardedByUser(now, answeredBoardingCheckId(intent)) latestRide = detector?.snapshot(now) ?: RideSnapshot() } boardingAlertSent = true @@ -214,7 +214,7 @@ class RouteTripMonitorService : Service() { synchronized(detectorLock) { // Only the bus that was asked about is set aside; the next // one still gets detected and asked about in its turn. - detector?.declineBoarding(now) + detector?.declineBoarding(now, answeredBoardingCheckId(intent)) latestRide = detector?.snapshot(now) ?: RideSnapshot() } boardingAlertSent = false @@ -1732,14 +1732,14 @@ class RouteTripMonitorService : Service() { NotificationCompat.Action.Builder( 0, "我已上車", - createMarkBoardedPendingIntent(), + createMarkBoardedPendingIntent(ride.boardingCheckId), ).build(), ) .addAction( NotificationCompat.Action.Builder( 0, "還沒上車", - createNotBoardedPendingIntent(), + createNotBoardedPendingIntent(ride.boardingCheckId), ).build(), ) .build(), @@ -1997,29 +1997,42 @@ class RouteTripMonitorService : Service() { ) } - private fun createMarkBoardedPendingIntent(): PendingIntent { + /** + * "I have boarded". [answeredCheckId] is set only on the buttons of the + * boarding question, which answer that question; the same words on the + * tracking notification are the rider speaking unprompted, and must not be + * read as an answer to a question they may not have been shown. + */ + private fun createMarkBoardedPendingIntent(answeredCheckId: Long = 0L): PendingIntent = + createBoardingAnswerPendingIntent(ACTION_MARK_BOARDED, 405, 407, answeredCheckId) + + private fun createNotBoardedPendingIntent(answeredCheckId: Long = 0L): PendingIntent = + createBoardingAnswerPendingIntent(ACTION_NOT_BOARDED, 406, 408, answeredCheckId) + + private fun createBoardingAnswerPendingIntent( + action: String, + unpromptedRequestCode: Int, + answerRequestCode: Int, + answeredCheckId: Long, + ): PendingIntent { val intent = Intent(this, RouteTripMonitorService::class.java).apply { - action = ACTION_MARK_BOARDED + this.action = action + if (answeredCheckId != 0L) { + putExtra(EXTRA_BOARDING_CHECK_ID, answeredCheckId) + } } + // Extras do not tell two PendingIntents apart, so the answer gets a + // request code of its own rather than rewriting the unprompted button. return PendingIntent.getService( this, - 405, + if (answeredCheckId != 0L) answerRequestCode else unpromptedRequestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } - private fun createNotBoardedPendingIntent(): PendingIntent { - val intent = Intent(this, RouteTripMonitorService::class.java).apply { - action = ACTION_NOT_BOARDED - } - return PendingIntent.getService( - this, - 406, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } + private fun answeredBoardingCheckId(intent: Intent): Long? = + intent.getLongExtra(EXTRA_BOARDING_CHECK_ID, 0L).takeIf { it != 0L } override fun onTaskRemoved(rootIntent: Intent?) { AppRuntimeStateStore.setAppInForeground(this, false) @@ -2302,6 +2315,7 @@ class RouteTripMonitorService : Service() { private const val EXTRA_SESSION_JSON = "session_json" private const val EXTRA_APP_IN_FOREGROUND = "app_in_foreground" private const val EXTRA_PAUSE_REASON = "pause_reason" + private const val EXTRA_BOARDING_CHECK_ID = "boarding_check_id" private const val LEGACY_TRACKING_CHANNEL_ID = "trip_monitor_tracking" private const val TRACKING_CHANNEL_ID = "trip_monitor_tracking_v2" diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index 4e0f403..cadd3fd 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -239,14 +239,24 @@ internal class TripRideDetector( } } - fun confirmBoardedByUser(nowMs: Long) { + /** + * The rider says they are on board. + * + * [answeredCheckId] is the [RideSnapshot.boardingCheckId] of the question + * being answered, and null when nothing was asked: "I have boarded" is + * always on offer, and a rider who uses it may never have seen a question — + * one can be pending, about a bus that just left, before it is ever shown. + * That bus is not theirs for having been about to be asked about. + */ + fun confirmBoardedByUser(nowMs: Long, answeredCheckId: Long? = null) { val identifiedVehicle = trackedVehicleId.takeIf { boarded } // A question that names a bus is about that bus: it was watched coming - // up to the stop and leaving it, and "yes" is the rider's answer to - // exactly that. Nothing seen afterwards can be tied to the ride as - // firmly, however near the stop it is. + // up to the stop and leaving it, and "yes" to that very question is the + // rider's answer to exactly that. An answer to a question since + // replaced by another names nothing. val askedAbout = pendingCheckVehicleId.takeIf { - pendingCheckSinceMs != 0L && nowMs - pendingCheckSinceMs in 0L..BOARDING_CHECK_MAX_AGE_MS + pendingCheckSinceMs != 0L && answeredCheckId == pendingCheckSinceMs && + nowMs - pendingCheckSinceMs in 0L..BOARDING_CHECK_MAX_AGE_MS } if (!boarded || evidence != BoardingEvidence.USER_CONFIRMED) { manualConfirmationAtMs = nowMs @@ -290,8 +300,17 @@ internal class TripRideDetector( * Switching detection off for the rest of the trip instead would leave the * rider sitting on their real bus with no boarding alert and no destination * reminders, so one repeated question is the better failure. + * + * [answeredCheckId] is the question being answered, as for + * [confirmBoardedByUser]. "Not yet" to a question that has since been + * replaced says nothing about the bus the new one is about. */ - fun declineBoarding(nowMs: Long) { + fun declineBoarding(nowMs: Long, answeredCheckId: Long? = null) { + if (!boarded && answeredCheckId != null && answeredCheckId != pendingCheckSinceMs) { + // Still true of the rider's movement so far, and of nothing else. + lastDeclineAtMs = nowMs + return + } val answeredAQuestion = pendingCheckSinceMs != 0L val rejected = pendingCheckVehicleId ?: trackedVehicleId boarded = false @@ -736,6 +755,9 @@ internal class TripRideDetector( * can reach the app afterwards — the feed runs half a minute behind — which * is what the two-minute window is for. A bus that pulls in after the * rider said they were already on one is the next bus, whenever it is seen. + * + * Nor is it a bus the app had already watched leave by then. Unprompted, + * "I have boarded" points at what is standing at the stop. */ private fun identifyBoardingVehicle(nowMs: Long): String? { val confirmedAt = manualConfirmationAtMs ?: return null @@ -760,7 +782,14 @@ internal class TripRideDetector( if (nowMs - latest.receivedAtMs !in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS) { return@filter false } - track.reversed().takeWhile { it.samePath && it.onRoute }.any { sample -> + val run = track.reversed().takeWhile { it.samePath && it.onRoute } + // A bus already seen pulling away before the rider spoke up is one + // the app knew had gone: whether they were on it is what the + // boarding question asks, and only an answer to that settles it. + val hadLeftBeforeConfirming = run.any { sample -> + sample.gpsTimeMs <= confirmedAt && sample.alongMeters - boardingAlong > LEFT_STOP_MARGIN_METERS + } + !hadLeftBeforeConfirming && run.any { sample -> confirmedAt - sample.gpsTimeMs in 0L..MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS && abs(sample.alongMeters - boardingAlong) <= AT_STOP_MARGIN_METERS && line.metersToStop(sample.latitude, sample.longitude, boardingStop) <= diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index cad20bc..886ae5f 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -547,6 +547,61 @@ class RouteTripMonitorServiceTest { ) } + @Test + fun onlyTheQuestionsOwnButtonsAnswerTheQuestion() { + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 3, + ) + for (answersTheQuestion in listOf(false, true)) { + // Bus A calls at the boarding stop and leaves. + var busStop = stopsOf(trip).first() + var gpsTime = nowSeconds() + backend = { path -> + if (path.endsWith("/realtime/buses")) { + busFeed("BUS-A", busStop, pathId = 0, gpsTimeSeconds = gpsTime) + } else { + null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + refreshAndDeliverAlerts() + busStop = stopsOf(trip)[1] + gpsTime += 20 + ShadowSystemClock.advanceBy(20, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + val checkId = ride(service).boardingCheckId + assertTrue("A's departure must be pending as a question", checkId != 0L) + val notifications = service.getSystemService(NotificationManager::class.java) + + if (!answersTheQuestion) { + // Not asked yet. The rider is on another bus, one with no + // position feed, and says so on the tracking notification. + assertTrue(notifications.activeNotifications.none { it.id == 6023 }) + service.onStartCommand(buttonIntent(notificationId = 6021, title = "我已上車"), 0, 2) + + assertTrue(ride(service).hasBoarded) + assertNull("an unseen question's bus is not the rider's", ride(service).trackedVehicleId) + } else { + // The question is shown 40 s after the departure. The service + // reads the JVM wall clock, so the departure is aged instead. + ReflectionHelpers.setField( + ReflectionHelpers.getField(service, "detector"), + "pendingCheckSinceMs", + System.currentTimeMillis() - 45_000L, + ) + refreshAndDeliverAlerts() + assertFalse( + "showing the question must not turn the tracking notification's button into an answer", + buttonIntent(notificationId = 6021, title = "我已上車").hasExtra("boarding_check_id"), + ) + service.onStartCommand(buttonIntent(notificationId = 6023, title = "我已上車"), 0, 2) + + assertEquals("yes to the question about A is a ride on A", "BUS-A", ride(service).trackedVehicleId) + } + service.onStartCommand(command("STOP_TRIP_MONITOR"), 0, 3) + } + } + @Test fun aFailingStopFeedDoesNotSilenceTheVehicleFeed() { val requests = java.util.concurrent.CopyOnWriteArrayList() @@ -1190,6 +1245,17 @@ class RouteTripMonitorServiceTest { """{"paths":[{"stops":[{"stopid":"$stopId","eta":$eta,"etas":[{"plate":"$plate","eta":$eta}]}]}]}""" /** One vehicle at [stop]; direction is unknown unless supplied. */ + /** What tapping the button called [title] on a posted notification sends the service. */ + private fun buttonIntent(notificationId: Int, title: String): Intent { + val posted = service.getSystemService(NotificationManager::class.java).activeNotifications + val buttons = posted.firstOrNull { it.id == notificationId }?.notification?.actions.orEmpty() + val button = buttons.firstOrNull { it.title.toString() == title } ?: throw AssertionError( + "no \"$title\" on notification $notificationId; posted: " + + posted.joinToString { n -> "${n.id}${n.notification.actions.orEmpty().map { it.title }}" }, + ) + return shadowOf(button.actionIntent).savedIntent + } + /** Vehicles stamp their reports with the same clock the phone runs on. */ private fun nowSeconds() = System.currentTimeMillis() / 1000 diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt index 53192d4..e88f0df 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt @@ -71,12 +71,16 @@ class TripRideDetectorManualBoardingTest { } @Test - fun aRecentBoundedDepartureCanBeIdentified() { + fun aBusStillAtTheStopWhenTheRiderConfirmsIsTheirs() { + // Its next report, already made but not yet delivered, shows it moving + // off with them on board. val detector = newDetector() - detector.onBusReports(listOf(report(0, along = 0.0), report(20, along = 200.0))) - detector.confirmBoardedByUser(at(20)) + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.confirmBoardedByUser(at(15)) + assertEquals(BUS, detector.snapshot(at(15)).trackedVehicleId) - assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) + detector.onBusReports(listOf(report(40, along = 300.0))) + assertEquals(BUS, detector.snapshot(at(40)).trackedVehicleId) } @Test @@ -118,8 +122,9 @@ class TripRideDetectorManualBoardingTest { detector.onBusReports(listOf(report(20, along = 150.0))) assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) detector.onBusReports(listOf(report(60, along = 700.0), report(60, along = 0.0, id = "OTHER"))) - assertTrue(detector.snapshot(at(60)).boardingPromptDue) - detector.confirmBoardedByUser(at(70)) + val question = detector.snapshot(at(60)) + assertTrue(question.boardingPromptDue) + detector.confirmBoardedByUser(at(70), answeredCheckId = question.boardingCheckId) val ride = detector.snapshot(at(70)) assertEquals(BUS, ride.trackedVehicleId) @@ -127,16 +132,79 @@ class TripRideDetectorManualBoardingTest { assertTrue(ride.hasRouteValidProgress) } + @Test + fun confirmingUnpromptedDoesNotAdoptABusAboutToBeAskedAbout() { + // Bus A leaves and a question about it is pending, but is not shown for + // another 40 s. Meanwhile the rider gets on bus B, which has no + // position feed, and says so with the button that is always there. + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + val pending = detector.snapshot(at(20)) + assertTrue(pending.boardingCheckId != 0L) + assertFalse("the rider has not been asked anything yet", pending.boardingPromptDue) + detector.confirmBoardedByUser(at(30)) + + assertUnidentified(detector.snapshot(at(30))) + // A carries on past the rider's destination; the ride is not on it. + detector.onBusReports(listOf(report(150, along = 2_000.0, id = "BUS-A"))) + assertUnidentified(detector.snapshot(at(150))) + } + + @Test + fun confirmingUnpromptedWhileAQuestionIsShowingIsStillNotAnAnswerToIt() { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + detector.snapshot(at(20)) + detector.onBusReports(listOf(report(60, along = 700.0, id = "BUS-A"))) + assertTrue(detector.snapshot(at(60)).boardingPromptDue) + detector.confirmBoardedByUser(at(70)) + + assertUnidentified(detector.snapshot(at(70))) + } + + @Test + fun anAnswerToAQuestionSinceReplacedNamesNoBus() { + // The notification about bus A is still on screen when bus B leaves and + // becomes the pending question; the rider answers the one they can see. + for (boarded in listOf(true, false)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + val aboutA = detector.snapshot(at(20)).boardingCheckId + detector.onBusReports(listOf(report(100, along = 0.0, id = "BUS-B"))) + detector.snapshot(at(100)) + detector.onBusReports(listOf(report(120, along = 150.0, id = "BUS-B"))) + val aboutB = detector.snapshot(at(120)).boardingCheckId + assertTrue("B's departure must have replaced A's question", aboutB != 0L && aboutB != aboutA) + + if (boarded) { + detector.confirmBoardedByUser(at(125), answeredCheckId = aboutA) + assertUnidentified(detector.snapshot(at(125))) + } else { + detector.declineBoarding(at(125), answeredCheckId = aboutA) + val ride = detector.snapshot(at(165)) + assertEquals("the question about B still stands", aboutB, ride.boardingCheckId) + assertTrue(ride.boardingPromptDue) + } + } + } + @Test fun aQuestionThatHasLapsedNamesNoBus() { val detector = newDetector() detector.onBusReports(listOf(report(0, along = 0.0))) detector.snapshot(at(0)) detector.onBusReports(listOf(report(20, along = 150.0))) - assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + val question = detector.snapshot(at(20)).boardingCheckId + assertTrue(question != 0L) detector.onBusReports(listOf(report(330, along = 2_400.0))) // Not snapshotted in between, so the question was never retired. - detector.confirmBoardedByUser(at(330)) + detector.confirmBoardedByUser(at(330), answeredCheckId = question) assertUnidentified(detector.snapshot(at(330))) } @@ -255,8 +323,9 @@ class TripRideDetectorManualBoardingTest { detector.onBusReports(listOf(report(0, along = 0.0))) detector.snapshot(at(0)) detector.onBusReports(listOf(report(20, along = 150.0), report(20, along = 0.0, id = "OTHER"))) - assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) - detector.confirmBoardedByUser(at(20)) + val question = detector.snapshot(at(20)).boardingCheckId + assertTrue(question != 0L) + detector.confirmBoardedByUser(at(20), answeredCheckId = question) assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) } From 8901d97ec5fd582d3dab41d0efd1174bf8de403a Mon Sep 17 00:00:00 2001 From: AvianJay Date: Sun, 15 Nov 2026 23:02:22 +0800 Subject: [PATCH 10/11] fix(android): tell apart buses that leave the stop close together A departure within ninety seconds of the pending question was taken for the same bus whichever vehicle it was. Two buses leaving close together left the question naming the first, and "yes" from a rider on the second followed the wrong bus. - Two reports are one departure only when they can be: one from the position feed and one from the ETA rolling over, or the same vehicle again. A different vehicle is another bus, and joins what the question is about. Ninety seconds or more after the last time of asking, a rider still at the stop is asked afresh. - "Yes" names a bus only when exactly one has left while the question stood, the position feed named it, and no other bus is where the rider could have boarded it since. "Not yet" sets aside every bus that left. - Unprompted, the bus at the stop is adopted only when it is the only one there and no bus that has left is still unanswered for. - An answer is read against the reports received so far rather than the last snapshot, and a manual confirmation no longer forgets the buses the rider turned down. The new service test stamps its vehicle reports in the past: the service reads the JVM wall clock, and reports dated ahead of it hid the bug. All 178 Android unit tests pass; each rule fails a test when reverted. --- .../taiwanbus/flutter/TripRideDetector.kt | 279 ++++++++++++---- .../flutter/RouteTripMonitorServiceTest.kt | 70 +++- .../TripRideDetectorManualBoardingTest.kt | 311 +++++++++++++++++- 3 files changed, 578 insertions(+), 82 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index cadd3fd..04968ea 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -116,22 +116,28 @@ internal class TripRideDetector( * of that answer. The answer is about one bus, not about the trip: only the * vehicle it named is set aside, and only for [REJECTED_VEHICLE_TTL_MS], so * the next bus both raises the question and gets detected as normal. See - * [declineBoarding]. + * [declineBoarding]. A later "I have boarded" leaves these standing: it is + * said of some other bus, and they are how that bus is told from these. */ private val rejectedAt = HashMap() private var lastDeclineAtMs = 0L /** - * The departure behind the last "not yet" answer: when it was registered, - * and whether the bus it belonged to is still unknown because only the ETA - * had reported it. + * The departures the last "not yet" answered, kept for as long as the other + * feed may still report them; and when a detected ride was last turned down. */ - private var declinedDepartureAtMs = 0L - private var declinedDepartureUnattributed = false + private val declinedDepartures = ArrayList() + private var declinedRideAtMs = 0L private var etaArrivingAtMs = 0L + + /** + * The pending "did you get on?" question: its id, and every bus that has + * left the boarding stop while it has stood. The question says only that a + * bus has just left, so it is about all of them. + */ private var pendingCheckSinceMs = 0L - private var pendingCheckVehicleId: String? = null + private val pendingDepartures = ArrayList() private var pendingCheckAnchor: UserSample? = null private val boardingIndex: Int @@ -246,31 +252,78 @@ internal class TripRideDetector( * being answered, and null when nothing was asked: "I have boarded" is * always on offer, and a rider who uses it may never have seen a question — * one can be pending, about a bus that just left, before it is ever shown. - * That bus is not theirs for having been about to be asked about. + * + * Either way this confirms the ride, not a plate. A vehicle is named only + * when the feeds leave one bus the rider can be on: in answer to the + * question, the one bus that left while it stood ([busTheQuestionIsAbout]); + * unprompted, the one bus standing at the stop ([identifyBoardingVehicle]). + * Following the wrong bus sounds the destination reminders for a journey + * the rider is not making, so anything less leaves the vehicle unknown + * until the rider's own position picks it out. */ fun confirmBoardedByUser(nowMs: Long, answeredCheckId: Long? = null) { + noteDepartures(nowMs) val identifiedVehicle = trackedVehicleId.takeIf { boarded } - // A question that names a bus is about that bus: it was watched coming - // up to the stop and leaving it, and "yes" to that very question is the - // rider's answer to exactly that. An answer to a question since - // replaced by another names nothing. - val askedAbout = pendingCheckVehicleId.takeIf { - pendingCheckSinceMs != 0L && answeredCheckId == pendingCheckSinceMs && - nowMs - pendingCheckSinceMs in 0L..BOARDING_CHECK_MAX_AGE_MS + // An answer to a question since replaced by another names nothing. + val askedAbout = if (answeredCheckId != null && answeredCheckId == pendingCheckSinceMs) { + busTheQuestionIsAbout(nowMs) + } else { + null } if (!boarded || evidence != BoardingEvidence.USER_CONFIRMED) { - manualConfirmationAtMs = nowMs - manualVehicleAmbiguous = false + // Only unprompted is "I have boarded" read against what is standing + // at the stop; an answer names a bus through its question or not + // at all. + manualConfirmationAtMs = nowMs.takeIf { answeredCheckId == null } + // And not even unprompted while a bus that has left is still to be + // answered for: the rider may be on that one, and has not said. + manualVehicleAmbiguous = departuresTheQuestionCovers(nowMs).isNotEmpty() } boarded = true evidence = BoardingEvidence.USER_CONFIRMED - rejectedAt.clear() - // Confirming the ride does not confirm a plate. An already identified - // vehicle remains the ride even if another bus is nearer the stop now. + // An already identified vehicle remains the ride even if another bus + // is nearer the stop now. trackedVehicleId = identifiedVehicle ?: askedAbout ?: identifyBoardingVehicle(nowMs) clearPendingCheck() } + /** + * Counts the buses that have left the boarding stop according to the + * reports received so far. What an answer means depends on that count, and + * it must not depend on whether a snapshot happened to be taken between + * the last report coming in and the rider answering. + */ + private fun noteDepartures(nowMs: Long) { + val boardingStop = boardingIndex + if (boardingStop >= 0) { + trackDepartures(liveVehicles(nowMs), line.alongOfStop(boardingStop), nowMs) + } + } + + /** The buses that have left the boarding stop while the pending question has stood. */ + private fun departuresTheQuestionCovers(nowMs: Long): List = + pendingDepartures.filter { nowMs - it.atMs in 0L..BOARDING_CHECK_MAX_AGE_MS } + + /** + * The one bus a "yes" to the pending question can mean; null when that + * cannot be told. + * + * The question does not name a bus — riders do not know plates — only says + * that one has just left. So the answer names a vehicle when exactly one + * has left while the question stood, the position feed named it, and no + * other bus is at the stop for the rider to have boarded since. Two that + * left close together, or one that left as another pulled in, leave "yes" + * true of either. + */ + private fun busTheQuestionIsAbout(nowMs: Long): String? { + val vehicleId = departuresTheQuestionCovers(nowMs).singleOrNull()?.vehicleId ?: return null + val boardingStop = boardingIndex + if (boardingStop < 0 || anotherBusIsBoardable(nowMs, line.alongOfStop(boardingStop), vehicleId)) { + return null + } + return vehicleId + } + /** * Takes up a ride the rider confirmed earlier, on a detector built since. * @@ -290,12 +343,13 @@ internal class TripRideDetector( /** * The user says they are not on the bus the app thinks they are on. * - * The answer is about one bus, not about the trip, so only what was just - * asserted is set aside: the vehicle of the pending question if there is - * one, else the vehicle the detected ride named, for - * [REJECTED_VEHICLE_TTL_MS]; and the rider's own movement up to this - * moment, which no longer counts as evidence of a ride. The next bus raises - * its own question and is detected exactly as this one was. + * The answer is about the buses that have gone, not about the trip, so only + * what was just asserted is set aside: every bus that left while the + * pending question stood — a rider still waiting is on none of them — else + * the vehicle the detected ride named, for [REJECTED_VEHICLE_TTL_MS]; and + * the rider's own movement up to this moment, which no longer counts as + * evidence of a ride. The next bus raises its own question and is detected + * exactly as these were. * * Switching detection off for the rest of the trip instead would leave the * rider sitting on their real bus with no boarding alert and no destination @@ -303,27 +357,31 @@ internal class TripRideDetector( * * [answeredCheckId] is the question being answered, as for * [confirmBoardedByUser]. "Not yet" to a question that has since been - * replaced says nothing about the bus the new one is about. + * replaced says nothing about the buses the new one is about. */ fun declineBoarding(nowMs: Long, answeredCheckId: Long? = null) { + noteDepartures(nowMs) if (!boarded && answeredCheckId != null && answeredCheckId != pendingCheckSinceMs) { // Still true of the rider's movement so far, and of nothing else. lastDeclineAtMs = nowMs return } - val answeredAQuestion = pendingCheckSinceMs != 0L - val rejected = pendingCheckVehicleId ?: trackedVehicleId + val askedAbout = pendingDepartures.toList() + val rejected = if (askedAbout.isEmpty()) { + listOfNotNull(trackedVehicleId) + } else { + askedAbout.mapNotNull { it.vehicleId } + } boarded = false evidence = BoardingEvidence.NONE trackedVehicleId = null manualConfirmationAtMs = null manualVehicleAmbiguous = false lastDeclineAtMs = nowMs - declinedDepartureAtMs = if (answeredAQuestion) pendingCheckSinceMs else nowMs - declinedDepartureUnattributed = answeredAQuestion && rejected == null - if (rejected != null) { - rejectedAt[rejected] = nowMs - } + declinedDepartures.clear() + declinedDepartures += askedAbout + declinedRideAtMs = if (askedAbout.isEmpty()) nowMs else 0L + rejected.forEach { rejectedAt[it] = nowMs } clearPendingCheck() } @@ -605,6 +663,16 @@ internal class TripRideDetector( } } + /** + * Counts a bus leaving the boarding stop, which is what the question is + * asked about. + * + * One departure can reach the app twice, up to a minute and a half apart: + * from the position feed, which names the vehicle, and from the ETA rolling + * over, which does not. Two reports are taken for one departure only when + * they can be: one from each feed, or the same vehicle again. Two different + * vehicles are two buses however close together they leave. + */ private fun registerDeparture(vehicleId: String?, nowMs: Long) { if (boarded) { return @@ -615,28 +683,7 @@ internal class TripRideDetector( // every other vehicle is untouched. return } - if (declinedDepartureAtMs != 0L && nowMs - declinedDepartureAtMs < DEPARTURE_DEDUPE_MS) { - // One departure reaches the app twice — from the position feed and - // from the ETA rolling over — up to a minute and a half apart, and - // this one has already been answered. The window runs from the - // departure, not from the answer: a rider may take minutes to - // reply, and what leaves the stop after that is the next bus. - if (vehicleId == null) { - return - } - if (declinedDepartureUnattributed) { - // The question came from the ETA alone, so the answer had no - // vehicle to be held against. This is that bus, now named. - declinedDepartureUnattributed = false - rejectedAt[vehicleId] = lastDeclineAtMs - return - } - // A different vehicle this close behind is a second bus, and gets - // a question of its own. - } - if (pendingCheckSinceMs != 0L && nowMs - pendingCheckSinceMs < DEPARTURE_DEDUPE_MS) { - // The position feed and the ETA rollover both report the same bus. - pendingCheckVehicleId = pendingCheckVehicleId ?: vehicleId + if (answeredByTheLastDecline(vehicleId, nowMs)) { return } val boardingStop = boardingIndex @@ -648,14 +695,75 @@ internal class TripRideDetector( line.metersToStop(sample.latitude, sample.longitude, boardingStop) <= BOARDING_STOP_RADIUS_METERS } + if (pendingCheckSinceMs != 0L) { + if (coveredByThePendingQuestion(vehicleId, nowMs)) { + return + } + // Another bus has left with the question unanswered. It joins what + // the question is about rather than taking it over: the rider may + // be on either, and "yes" will not say which. + pendingDepartures.removeAll { nowMs - it.atMs > BOARDING_CHECK_MAX_AGE_MS } + pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = vehicleId == null) + if (userWasAtStop && nowMs - pendingCheckSinceMs >= DEPARTURE_DEDUPE_MS) { + // Long enough after the last time of asking to ask afresh. + pendingCheckSinceMs = nowMs + pendingCheckAnchor = recentUser.lastOrNull() + } + return + } if (!userWasAtStop) { return } pendingCheckSinceMs = nowMs - pendingCheckVehicleId = vehicleId + pendingDepartures.clear() + pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = vehicleId == null) pendingCheckAnchor = recentUser.lastOrNull() } + /** + * Whether this report is of a departure the rider's last "not yet" already + * covered, reaching the app a second time. + */ + private fun answeredByTheLastDecline(vehicleId: String?, nowMs: Long): Boolean { + // The window runs from the departure, not from the answer: a rider may + // take minutes to reply, and what leaves the stop after that is the + // next bus. + declinedDepartures.removeAll { nowMs - it.atMs >= DEPARTURE_DEDUPE_MS } + if (vehicleId == null) { + // The ETA catching up with what the rider has just answered for. + return declinedDepartures.isNotEmpty() || + (declinedRideAtMs != 0L && nowMs - declinedRideAtMs < DEPARTURE_DEDUPE_MS) + } + // The question came from the ETA alone, so the answer had no vehicle to + // be held against. This is that bus, now named. Any other vehicle this + // close behind is a second bus, and gets a question of its own. + val unnamed = declinedDepartures.firstOrNull { it.vehicleId == null } ?: return false + unnamed.vehicleId = vehicleId + rejectedAt[vehicleId] = lastDeclineAtMs + return true + } + + /** + * Whether this report is the other feed's account, or a repeat, of a + * departure the pending question already covers. + */ + private fun coveredByThePendingQuestion(vehicleId: String?, nowMs: Long): Boolean { + if (vehicleId != null && pendingDepartures.any { it.vehicleId == vehicleId }) { + return true + } + val recent = pendingDepartures.filter { nowMs - it.atMs < DEPARTURE_DEDUPE_MS } + if (vehicleId == null) { + // The ETA's account of a bus the position feed has reported. + val unreported = recent.firstOrNull { !it.reportedByEta } ?: return false + unreported.reportedByEta = true + return true + } + // The position feed naming a bus only the ETA had reported. + val unnamed = recent.firstOrNull { it.vehicleId == null } ?: return false + unnamed.vehicleId = vehicleId + return true + } + private fun resolveBoardingPromptDue( nowMs: Long, usableUser: UserSample?, @@ -700,7 +808,7 @@ internal class TripRideDetector( private fun clearPendingCheck() { pendingCheckSinceMs = 0L - pendingCheckVehicleId = null + pendingDepartures.clear() pendingCheckAnchor = null } @@ -743,7 +851,8 @@ internal class TripRideDetector( } /** - * Associates a manual confirmation with a vehicle, not just a route. + * Associates an unprompted "I have boarded" with a vehicle, not just a + * route. * * The bus must have an unbroken run on this direction and route containing * a fix physically at the boarding stop, and still be at most 250 m ahead. @@ -756,8 +865,11 @@ internal class TripRideDetector( * is what the two-minute window is for. A bus that pulls in after the * rider said they were already on one is the next bus, whenever it is seen. * - * Nor is it a bus the app had already watched leave by then. Unprompted, - * "I have boarded" points at what is standing at the stop. + * Nor is it a bus the app had already watched leave by then: whether the + * rider was on that one is what the boarding question asks. + * + * And it must be the only bus there. With another at the stop, just gone + * from it, or near enough to have pulled in, the rider can be on either. */ private fun identifyBoardingVehicle(nowMs: Long): String? { val confirmedAt = manualConfirmationAtMs ?: return null @@ -796,13 +908,37 @@ internal class TripRideDetector( MANUAL_AT_STOP_RADIUS_METERS } } - if (candidates.size > 1) { - // One candidate later leaving the distance bound does not prove - // that the rider took the other one. A usable phone fix can still - // identify the ride through resolveTrackedVehicle(). + val candidate = candidates.singleOrNull() + if (candidates.size > 1 || + (candidate != null && anotherBusIsBoardable(confirmedAt, boardingAlong, candidate.vehicleId)) + ) { + // Which of them the rider took is not something a later report + // settles: one leaving the distance bound first proves nothing. A + // usable phone fix can still identify the ride through + // resolveTrackedVehicle(). manualVehicleAmbiguous = true + return null + } + return candidate?.vehicleId + } + + /** + * Whether the feeds place a vehicle other than [except] where the rider + * could have boarded it around [atMs]: at the boarding stop, just pulling + * away from it, or near enough short of it to have pulled in since — a + * report is half a minute old by the time it arrives. A bus the rider has + * turned down is not one of them. + */ + private fun anotherBusIsBoardable(atMs: Long, boardingAlong: Double, except: String): Boolean { + return busTracks.any { (vehicleId, track) -> + if (vehicleId == except || isRejected(vehicleId, atMs)) { + return@any false + } + val last = track.lastOrNull { it.gpsTimeMs <= atMs } ?: return@any false + last.samePath && last.onRoute && + atMs - last.receivedAtMs <= BUS_REPORT_STALE_MS && + last.alongMeters - boardingAlong in -BOARDABLE_APPROACH_METERS..MANUAL_VEHICLE_MAX_AHEAD_METERS } - return candidates.singleOrNull()?.vehicleId } private fun liveVehicles(nowMs: Long): List { @@ -878,6 +1014,16 @@ internal class TripRideDetector( val onRoute: Boolean, ) + /** + * One bus leaving the boarding stop. [vehicleId] is set once the position + * feed has reported it, [reportedByEta] once the ETA rolling over has. + */ + private class Departure( + val atMs: Long, + var vehicleId: String?, + var reportedByEta: Boolean, + ) + private data class LiveVehicle( val vehicleId: String, val alongMeters: Double, @@ -935,6 +1081,7 @@ internal class TripRideDetector( const val MANUAL_VEHICLE_MAX_AHEAD_METERS = 250.0 const val MANUAL_VEHICLE_EVIDENCE_MAX_AGE_MS = 90_000L const val MANUAL_IDENTIFICATION_WINDOW_MS = 120_000L + const val BOARDABLE_APPROACH_METERS = 300.0 const val ETA_ARRIVING_SECONDS = 30 const val ETA_ROLLOVER_SECONDS = 240 diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index 886ae5f..22c4fc9 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -602,6 +602,63 @@ class RouteTripMonitorServiceTest { } } + @Test + fun theAnswerAfterTwoBusesLeftCloseTogetherFollowsNeither() { + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 2, + ) + val stops = stopsOf(trip) + // Both stand at the boarding stop; A leaves, then B right behind it. + // The service reads the JVM wall clock, which does not move with the + // test's, so the reports are stamped in the minute leading up to now: + // a vehicle's clock must never run ahead of the phone's. + var busA = stops[0] + var busB = stops[0] + var gpsTime = nowSeconds() - 60 + backend = { path -> + if (path.endsWith("/realtime/buses")) { + busFeeds( + busFeed("BUS-A", busA, pathId = 0, gpsTimeSeconds = gpsTime), + busFeed("BUS-B", busB, pathId = 0, gpsTimeSeconds = gpsTime), + ) + } else { + null + } + } + service.onStartCommand(startIntent(trip), 0, 1) + refreshAndDeliverAlerts() + busA = stops[1] + gpsTime += 20 + ShadowSystemClock.advanceBy(20, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + val asked = ride(service).boardingCheckId + assertTrue("A leaving must raise the question", asked != 0L) + busA = stops[2] + busB = stops[1] + gpsTime += 20 + ShadowSystemClock.advanceBy(20, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + assertEquals("B leaving right behind is part of the same question", asked, ride(service).boardingCheckId) + + // The question is shown 40 s after the first departure. The service + // reads the JVM wall clock, so that is aged instead of waited for. + ReflectionHelpers.setField( + ReflectionHelpers.getField(service, "detector"), + "pendingCheckSinceMs", + System.currentTimeMillis() - 45_000L, + ) + refreshAndDeliverAlerts() + service.onStartCommand(buttonIntent(notificationId = 6023, title = "我已上車"), 0, 2) + refreshAndDeliverAlerts() + + // The rider is on B. A, a stop past their destination, is not their bus. + assertTrue(ride(service).hasBoarded) + assertNull("the answer is as true of either bus", ride(service).trackedVehicleId) + assertNull(ride(service).passedDestinationByStops) + assertEquals(0L, ReflectionHelpers.getField(service, "arrivalDetectedAtMs")) + assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) + } + @Test fun aFailingStopFeedDoesNotSilenceTheVehicleFeed() { val requests = java.util.concurrent.CopyOnWriteArrayList() @@ -945,10 +1002,17 @@ class RouteTripMonitorServiceTest { val trip = threeStopSession() + mapOf( "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 3, ) + // The variant's bus is at the stop before the rider confirms; the app + // only hears of it once the variant is known. + val reportedAt = nowSeconds() backend = { path -> if (path.endsWith("/realtime/buses")) { requests += path - if (path.contains("/TXG1/")) "[]" else busFeed("VARIANT-001", stopsOf(trip).first()) + if (path.contains("/TXG1/")) { + "[]" + } else { + busFeed("VARIANT-001", stopsOf(trip).first(), gpsTimeSeconds = reportedAt) + } } else { null } @@ -1256,6 +1320,10 @@ class RouteTripMonitorServiceTest { return shadowOf(button.actionIntent).savedIntent } + /** Several vehicles in one response. */ + private fun busFeeds(vararg feeds: String): String = + feeds.joinToString(prefix = "[", postfix = "]", separator = ",") { it.removeSurrounding("[", "]") } + /** Vehicles stamp their reports with the same clock the phone runs on. */ private fun nowSeconds() = System.currentTimeMillis() / 1000 diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt index e88f0df..99e934d 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt @@ -121,7 +121,7 @@ class TripRideDetectorManualBoardingTest { detector.snapshot(at(0)) detector.onBusReports(listOf(report(20, along = 150.0))) assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) - detector.onBusReports(listOf(report(60, along = 700.0), report(60, along = 0.0, id = "OTHER"))) + detector.onBusReports(listOf(report(60, along = 700.0))) val question = detector.snapshot(at(60)) assertTrue(question.boardingPromptDue) detector.confirmBoardedByUser(at(70), answeredCheckId = question.boardingCheckId) @@ -168,8 +168,9 @@ class TripRideDetectorManualBoardingTest { @Test fun anAnswerToAQuestionSinceReplacedNamesNoBus() { - // The notification about bus A is still on screen when bus B leaves and - // becomes the pending question; the rider answers the one they can see. + // The question about bus A has been on screen for a while when bus B + // leaves, and the rider is to be asked afresh. Until that shows, the + // notification they can see and answer is still the old one. for (boarded in listOf(true, false)) { val detector = newDetector() detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) @@ -179,8 +180,8 @@ class TripRideDetectorManualBoardingTest { detector.onBusReports(listOf(report(100, along = 0.0, id = "BUS-B"))) detector.snapshot(at(100)) detector.onBusReports(listOf(report(120, along = 150.0, id = "BUS-B"))) - val aboutB = detector.snapshot(at(120)).boardingCheckId - assertTrue("B's departure must have replaced A's question", aboutB != 0L && aboutB != aboutA) + val afresh = detector.snapshot(at(120)).boardingCheckId + assertTrue("B leaving this long after must be asked about afresh", afresh != 0L && afresh != aboutA) if (boarded) { detector.confirmBoardedByUser(at(125), answeredCheckId = aboutA) @@ -188,8 +189,12 @@ class TripRideDetectorManualBoardingTest { } else { detector.declineBoarding(at(125), answeredCheckId = aboutA) val ride = detector.snapshot(at(165)) - assertEquals("the question about B still stands", aboutB, ride.boardingCheckId) + assertEquals("the new question still stands", afresh, ride.boardingCheckId) assertTrue(ride.boardingPromptDue) + + // It is about both buses: A left unanswered for, not long ago. + detector.confirmBoardedByUser(at(170), answeredCheckId = afresh) + assertUnidentified(detector.snapshot(at(170))) } } } @@ -202,10 +207,12 @@ class TripRideDetectorManualBoardingTest { detector.onBusReports(listOf(report(20, along = 150.0))) val question = detector.snapshot(at(20)).boardingCheckId assertTrue(question != 0L) - detector.onBusReports(listOf(report(330, along = 2_400.0))) + // Another bus is standing at the stop by now. + detector.onBusReports(listOf(report(330, along = 2_400.0), report(325, along = 0.0, id = "OTHER"))) // Not snapshotted in between, so the question was never retired. detector.confirmBoardedByUser(at(330), answeredCheckId = question) + // "Yes" was to a question about a bus long gone, not about this one. assertUnidentified(detector.snapshot(at(330))) } @@ -318,16 +325,285 @@ class TripRideDetectorManualBoardingTest { } @Test - fun aValidNamedDepartureCanDisambiguateNearbyVehicles() { + fun twoBusesLeavingCloseTogetherAreNotOneDeparture() { + // A and B stand at the stop together. A leaves, and B forty seconds + // behind it, before the question about A has even been shown. The one + // question the rider then sees is about both. + val detector = newDetector() + detector.reportBoth(0, a = 0.0, b = 0.0) + detector.snapshot(at(0)) + detector.reportBoth(20, a = 150.0, b = 0.0) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue("A leaving must raise the question", asked != 0L) + detector.reportBoth(60, a = 600.0, b = 150.0) + val question = detector.snapshot(at(60)) + assertEquals("B leaving so soon after is asked about in the same breath", asked, question.boardingCheckId) + assertTrue(question.boardingPromptDue) + detector.reportBoth(80, a = 900.0, b = 400.0) + detector.snapshot(at(80)) + + // The rider got on B. "Yes" is as true of A, so it names neither. + detector.confirmBoardedByUser(at(85), answeredCheckId = asked) + assertUnidentified(detector.snapshot(at(85))) + + // A runs on past the rider's stop while B, with the rider, is short of it. + detector.reportBoth(200, a = 2_400.0, b = 1_200.0) + assertUnidentified(detector.snapshot(at(200))) + } + + @Test + fun aSecondBusAlreadyReportedGoneCountsBeforeAnySnapshotIsTaken() { + // The report that shows B gone as well arrives, and the rider answers + // before the detector has been asked to look again. + for (boarded in listOf(true, false)) { + val detector = newDetector() + detector.reportBoth(0, a = 0.0, b = 0.0) + detector.snapshot(at(0)) + detector.reportBoth(20, a = 150.0, b = 0.0) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue(asked != 0L) + detector.reportBoth(60, a = 600.0, b = 400.0) + + if (boarded) { + detector.confirmBoardedByUser(at(61), answeredCheckId = asked) + assertUnidentified(detector.snapshot(at(61))) + } else { + detector.declineBoarding(at(61), answeredCheckId = asked) + assertEquals( + "still waiting means on neither, so B is not asked about next", + 0L, + detector.snapshot(at(61)).boardingCheckId, + ) + } + } + } + + @Test + fun aRiderNoLongerAtTheStopIsNotAskedAfresh() { + // At the stop when A leaves; a few minutes on, some 350 m off to the + // side — not gone, but not where a bus is boarded — when B does. + val detector = newDetector() + detector.onUserLocation(RideGeoPoint(latitude(0.0), 120.6, at(0), accuracyMeters = 10f)) + detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue(asked != 0L) + for (seconds in 60L..200L step 20L) { + detector.onUserLocation(RideGeoPoint(latitude(0.0), 120.6034, at(seconds), accuracyMeters = 10f)) + } + detector.onBusReports(listOf(report(180, along = 0.0, id = "BUS-B"))) + detector.snapshot(at(180)) + detector.onBusReports(listOf(report(200, along = 150.0, id = "BUS-B"))) + val ride = detector.snapshot(at(200)) + + assertFalse(ride.hasBoarded) + assertEquals("B leaving is not a reason to ask someone who is not at the stop", asked, ride.boardingCheckId) + } + + @Test + fun aBusSeenLeavingTwiceIsOneDeparture() { + // A fix that wanders back to the stop makes the bus "leave" again. val detector = newDetector() detector.onBusReports(listOf(report(0, along = 0.0))) detector.snapshot(at(0)) - detector.onBusReports(listOf(report(20, along = 150.0), report(20, along = 0.0, id = "OTHER"))) - val question = detector.snapshot(at(20)).boardingCheckId - assertTrue(question != 0L) - detector.confirmBoardedByUser(at(20), answeredCheckId = question) + detector.onBusReports(listOf(report(20, along = 150.0))) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue(asked != 0L) + detector.onBusReports(listOf(report(40, along = 30.0))) + detector.snapshot(at(40)) + detector.onBusReports(listOf(report(60, along = 400.0))) + assertTrue(detector.snapshot(at(60)).boardingPromptDue) + detector.confirmBoardedByUser(at(70), answeredCheckId = asked) - assertEquals(BUS, detector.snapshot(at(20)).trackedVehicleId) + assertEquals(BUS, detector.snapshot(at(70)).trackedVehicleId) + } + + @Test + fun aBusNotHeardFromForThreeMinutesDoesNotLeaveTheAnswerOpen() { + // B was last reported at the boarding stop, 500 m along, and then + // nothing: a bus the feed can no longer place is not one to weigh. + for ((silentForSeconds, namesTheBus) in listOf(180L to false, 181L to true)) { + val detector = newDetector(boardingStopId = 2) + detector.onBusReports(listOf(report(70 - silentForSeconds, along = 500.0, id = "BUS-B"))) + detector.onBusReports(listOf(report(0, along = 500.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 650.0, id = "BUS-A"))) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue(asked != 0L) + detector.onBusReports(listOf(report(60, along = 1_200.0, id = "BUS-A"))) + detector.snapshot(at(60)) + detector.confirmBoardedByUser(at(70), answeredCheckId = asked) + + assertEquals( + "B last heard from $silentForSeconds s before the answer", + "BUS-A".takeIf { namesTheBus }, + detector.snapshot(at(70)).trackedVehicleId, + ) + } + } + + @Test + fun aBusTheRiderCouldHaveBoardedSinceLeavesTheAnswerOpen() { + // The boarding stop is the second one, 500 m along. A has left it and + // the rider is asked; "yes" comes with B at [bAlong]. + fun answerWithBAt(bAlong: Double): RideSnapshot { + val detector = newDetector(boardingStopId = 2) + detector.onBusReports(listOf(report(0, along = 500.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 650.0, id = "BUS-A"))) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue(asked != 0L) + detector.reportBoth(60, a = 1_200.0, b = bAlong) + assertTrue(detector.snapshot(at(60)).boardingPromptDue) + detector.confirmBoardedByUser(at(70), answeredCheckId = asked) + return detector.snapshot(at(70)) + } + // Standing at the stop; near enough to have pulled in since it last + // reported; just gone from the stop without having been seen arriving. + for (bAlong in listOf(500.0, 250.0, 700.0)) { + assertUnidentified(answerWithBAt(bAlong)) + } + // Too far short of the stop to have reached it, or long past it. + for (bAlong in listOf(150.0, 800.0)) { + assertEquals("BUS-A", answerWithBAt(bAlong).trackedVehicleId) + } + } + + @Test + fun unpromptedTheBusAtTheStopIsNotAdoptedWhileOneThatLeftIsUnanswered() { + // A has left and is waiting to be asked about. B pulls in. The rider + // says they are on board without having been asked: on A, already down + // the road, or on B — the words are the same. + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + assertTrue(detector.snapshot(at(20)).boardingCheckId != 0L) + detector.reportBoth(40, a = 450.0, b = 0.0) + assertFalse("nothing has been asked yet", detector.snapshot(at(40)).boardingPromptDue) + detector.confirmBoardedByUser(at(45)) + + assertUnidentified(detector.snapshot(at(45))) + // Nor does B become the ride for staying while A draws away. + detector.reportBoth(60, a = 700.0, b = 0.0) + assertUnidentified(detector.snapshot(at(60))) + detector.reportBoth(80, a = 950.0, b = 150.0) + assertUnidentified(detector.snapshot(at(80))) + } + + @Test + fun unpromptedASecondBusNearTheStopLeavesTheRideUnidentified() { + // A stands at the boarding stop, 500 m along; B is at [bAlong]. + fun confirmWithBAt(bAlong: Double): TripRideDetector { + val detector = newDetector(boardingStopId = 2) + detector.reportBoth(0, a = 500.0, b = bAlong) + detector.confirmBoardedByUser(at(5)) + return detector + } + // Coming up to the stop, or just pulling away from it. + for (bAlong in listOf(250.0, 600.0)) { + val detector = confirmWithBAt(bAlong) + assertUnidentified(detector.snapshot(at(5))) + // B moving on does not settle which of the two it was. + detector.reportBoth(30, a = 500.0, b = bAlong + 400.0) + assertUnidentified(detector.snapshot(at(30))) + } + // Too far back to have reached the stop. + assertEquals("BUS-A", confirmWithBAt(150.0).snapshot(at(5)).trackedVehicleId) + } + + @Test + fun unpromptedABusAlreadyTurnedDownDoesNotLeaveTheRideUnidentified() { + // A left and the rider said "not yet". It is held up just past the stop + // when B pulls in; the rider gets on B and says so. + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + val asked = detector.snapshot(at(20)).boardingCheckId + detector.onBusReports(listOf(report(60, along = 200.0, id = "BUS-A"))) + assertTrue(detector.snapshot(at(60)).boardingPromptDue) + detector.declineBoarding(at(65), answeredCheckId = asked) + detector.reportBoth(80, a = 210.0, b = 0.0) + detector.snapshot(at(80)) + detector.confirmBoardedByUser(at(85)) + + assertEquals("BUS-B", detector.snapshot(at(85)).trackedVehicleId) + // And its report at the stop may as well arrive after the rider spoke. + val late = newDetector() + late.onBusReports(listOf(report(0, along = 0.0, id = "BUS-A"))) + late.snapshot(at(0)) + late.onBusReports(listOf(report(20, along = 150.0, id = "BUS-A"))) + val askedLate = late.snapshot(at(20)).boardingCheckId + late.onBusReports(listOf(report(60, along = 200.0, id = "BUS-A"))) + late.snapshot(at(60)) + late.declineBoarding(at(65), answeredCheckId = askedLate) + late.confirmBoardedByUser(at(85)) + assertUnidentified(late.snapshot(at(85))) + late.onBusReports( + listOf( + report(100, along = 210.0, id = "BUS-A"), + report(80, along = 0.0, id = "BUS-B").copy(receivedAtMs = at(100)), + ), + ) + assertEquals("BUS-B", late.snapshot(at(100)).trackedVehicleId) + } + + @Test + fun theEtaCatchingUpWithABusAlreadySeenLeavingIsNotASecondBus() { + for (aSecondBusLeaves in listOf(false, true)) { + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.onBoardingStopEta(etaSeconds = 0, arriving = true, nowMs = at(0)) + detector.snapshot(at(0)) + detector.onBusReports(listOf(report(20, along = 150.0))) + val asked = detector.snapshot(at(20)).boardingCheckId + assertTrue(asked != 0L) + // Half a minute on the ETA rolls over to the next bus: the same + // departure, from the other feed. + detector.onBoardingStopEta(etaSeconds = 600, arriving = false, nowMs = at(50)) + if (aSecondBusLeaves) { + // Then it does so again. That bus has no position feed, and the + // rider may be on it. + detector.onBoardingStopEta(etaSeconds = 0, arriving = true, nowMs = at(60)) + detector.onBoardingStopEta(etaSeconds = 600, arriving = false, nowMs = at(80)) + } + detector.confirmBoardedByUser(at(85), answeredCheckId = asked) + + val ride = detector.snapshot(at(85)) + if (aSecondBusLeaves) assertUnidentified(ride) else assertEquals(BUS, ride.trackedVehicleId) + } + } + + @Test + fun notYetSetsAsideEveryBusThatHasLeft() { + val detector = newDetector() + detector.reportBoth(0, a = 0.0, b = 0.0) + detector.snapshot(at(0)) + detector.reportBoth(20, a = 150.0, b = 0.0) + val asked = detector.snapshot(at(20)).boardingCheckId + detector.reportBoth(60, a = 600.0, b = 150.0) + assertTrue(detector.snapshot(at(60)).boardingPromptDue) + // Still waiting: on neither. + detector.declineBoarding(at(70), answeredCheckId = asked) + assertEquals(0L, detector.snapshot(at(70)).boardingCheckId) + + // B is held up just past the stop when the next bus comes and goes. + detector.onBusReports(listOf(report(100, along = 200.0, id = "BUS-B"), report(100, along = 0.0))) + detector.snapshot(at(100)) + detector.onBusReports(listOf(report(120, along = 220.0, id = "BUS-B"), report(120, along = 150.0))) + val next = detector.snapshot(at(120)).boardingCheckId + assertTrue("the next bus is asked about in its turn", next != 0L) + detector.onBusReports(listOf(report(160, along = 230.0, id = "BUS-B"), report(160, along = 700.0))) + assertTrue(detector.snapshot(at(160)).boardingPromptDue) + detector.confirmBoardedByUser(at(170), answeredCheckId = next) + + assertEquals( + "B was answered for already, and does not make this answer doubtful", + BUS, + detector.snapshot(at(170)).trackedVehicleId, + ) } private fun assertUnidentified(ride: RideSnapshot) { @@ -339,13 +615,18 @@ class TripRideDetectorManualBoardingTest { assertFalse(ride.hasRouteValidProgress) } - private fun newDetector() = TripRideDetector( + /** Six stops 500 m apart; the rider gets off at the fourth. */ + private fun newDetector(boardingStopId: Int = 1) = TripRideDetector( stops = (0..5).map { index -> TrackingStop(index + 1, "stop-$index", index, latitude(index * 500.0), 120.6) }, - pathId = 0, boardingStopId = 1, destinationStopId = 4, + pathId = 0, boardingStopId = boardingStopId, destinationStopId = 4, ) + /** Buses A and B, reported together. */ + private fun TripRideDetector.reportBoth(seconds: Long, a: Double, b: Double) = + onBusReports(listOf(report(seconds, along = a, id = "BUS-A"), report(seconds, along = b, id = "BUS-B"))) + private fun report(seconds: Long, along: Double, id: String = BUS) = RideBusReport( id, 0, latitude(along), 120.6, gpsTimeMs = at(seconds), receivedAtMs = at(seconds), ) From 514d9c0c8782368731425c611bd710309d63d0a7 Mon Sep 17 00:00:00 2001 From: AvianJay Date: Mon, 16 Nov 2026 01:15:51 +0800 Subject: [PATCH 11/11] fix(android): do not pair an ETA rollover with a bus without evidence Two review findings about reconciling the ETA feed with the position feed. - A rollover naming no bus, within ninety seconds of a departure the position feed reported, was taken for that bus. It may as well be a second bus with no position feed, so the pair is still asked about once but an answer no longer names the bus that has a plate. The same holds in the other order. - The stop feed often says which bus is standing at the stop. That plate now travels with the rollover, which is the evidence that does tie it to a vehicle: the same plate is the same departure, another plate is another bus. - After "not yet", each declined bus absorbs one ETA rollover, not every rollover in the window: the next bus without a position feed is asked about. A bus the position feed shows still at the stop after a declined rollover is the next bus too, not that one named late. All 183 Android unit tests pass; each rule fails a test when reverted. --- .../flutter/RouteTripMonitorService.kt | 6 + .../taiwanbus/flutter/TripRideDetector.kt | 130 ++++++++++++++---- .../flutter/RouteTripMonitorServiceTest.kt | 40 ++++++ .../TripRideDetectorManualBoardingTest.kt | 102 ++++++++++++-- 4 files changed, 239 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt index 9f5a403..a509e92 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorService.kt @@ -878,12 +878,18 @@ class RouteTripMonitorService : Service() { liveStop.busesAtStop.isNotEmpty() || liveStop.message?.contains("進站") == true || liveStop.message?.contains("到站") == true + // Which bus that is, where the feed leaves no doubt: its leaving is + // what the ETA rolling over will report next. + val atStop = liveStop.busesAtStop.ifEmpty { + liveStop.etaEntries.filter { (it.seconds ?: 1) <= 0 }.mapNotNull { it.vehicleId } + }.mapNotNull(::normalizeVehicleId).distinct().singleOrNull() val now = System.currentTimeMillis() synchronized(detectorLock) { target.onBoardingStopEta( etaSeconds = liveStop.seconds ?: etaEntry?.seconds, arriving = arriving, nowMs = now, + arrivingVehicleId = atStop.takeIf { arriving }, ) } } diff --git a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt index 04968ea..d67c2b9 100644 --- a/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt +++ b/android/app/src/main/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetector.kt @@ -130,6 +130,7 @@ internal class TripRideDetector( private var declinedRideAtMs = 0L private var etaArrivingAtMs = 0L + private var etaArrivingVehicleId: String? = null /** * The pending "did you get on?" question: its id, and every bus that has @@ -229,9 +230,23 @@ internal class TripRideDetector( * rolls over to the next bus means a vehicle has left, which is the only * departure signal available when that vehicle has no position feed. */ - fun onBoardingStopEta(etaSeconds: Int?, arriving: Boolean, nowMs: Long) { + fun onBoardingStopEta( + etaSeconds: Int?, + arriving: Boolean, + nowMs: Long, + arrivingVehicleId: String? = null, + ) { if (arriving || (etaSeconds != null && etaSeconds <= ETA_ARRIVING_SECONDS)) { + if (etaArrivingAtMs == 0L || nowMs - etaArrivingAtMs > ETA_ROLLOVER_WINDOW_MS) { + etaArrivingVehicleId = null + } etaArrivingAtMs = nowMs + // Where the stop feed says which bus is standing there, the + // rollover that follows is that bus leaving — the one thing that + // ties an ETA departure to a vehicle. + if (arrivingVehicleId != null) { + etaArrivingVehicleId = arrivingVehicleId + } return } // Once the estimate reaches zero it either jumps to the next bus or @@ -240,8 +255,10 @@ internal class TripRideDetector( nowMs - etaArrivingAtMs <= ETA_ROLLOVER_WINDOW_MS && (etaSeconds == null || etaSeconds >= ETA_ROLLOVER_SECONDS) if (rolledOver) { + val departed = etaArrivingVehicleId etaArrivingAtMs = 0L - registerDeparture(vehicleId = null, nowMs = nowMs) + etaArrivingVehicleId = null + registerDeparture(vehicleId = departed, nowMs = nowMs, byEta = true) } } @@ -310,13 +327,18 @@ internal class TripRideDetector( * * The question does not name a bus — riders do not know plates — only says * that one has just left. So the answer names a vehicle when exactly one - * has left while the question stood, the position feed named it, and no - * other bus is at the stop for the rider to have boarded since. Two that + * has left while the question stood, a feed named it, no rollover of the + * ETA is unaccounted for beside it, and no other bus is at the stop for the + * rider to have boarded since. Two that * left close together, or one that left as another pulled in, leave "yes" * true of either. */ private fun busTheQuestionIsAbout(nowMs: Long): String? { - val vehicleId = departuresTheQuestionCovers(nowMs).singleOrNull()?.vehicleId ?: return null + val departure = departuresTheQuestionCovers(nowMs).singleOrNull() ?: return null + if (departure.mayBeTwoBuses) { + return null + } + val vehicleId = departure.vehicleId ?: return null val boardingStop = boardingIndex if (boardingStop < 0 || anotherBusIsBoardable(nowMs, line.alongOfStop(boardingStop), vehicleId)) { return null @@ -669,11 +691,17 @@ internal class TripRideDetector( * * One departure can reach the app twice, up to a minute and a half apart: * from the position feed, which names the vehicle, and from the ETA rolling - * over, which does not. Two reports are taken for one departure only when - * they can be: one from each feed, or the same vehicle again. Two different - * vehicles are two buses however close together they leave. + * over, which names it only where the stop feed says which bus was at the + * stop. Two reports naming the same vehicle are one departure, and two + * naming different vehicles are two, however close together. + * + * A rollover that names nothing, beside a departure the position feed + * reported, is not asked about twice — it is most often the same bus — but + * nothing shows that it is. It may be a second bus with no position feed, + * so the pair is remembered as possibly two ([Departure.mayBeTwoBuses]) + * and an answer does not name the one that happens to have a plate. */ - private fun registerDeparture(vehicleId: String?, nowMs: Long) { + private fun registerDeparture(vehicleId: String?, nowMs: Long, byEta: Boolean = false) { if (boarded) { return } @@ -681,9 +709,12 @@ internal class TripRideDetector( // The rider already said this bus was not theirs. Asking again // about the same one is the noise the rejection exists to avoid; // every other vehicle is untouched. + if (byEta) { + declinedDepartures.firstOrNull { it.vehicleId == vehicleId }?.reportedByEta = true + } return } - if (answeredByTheLastDecline(vehicleId, nowMs)) { + if (answeredByTheLastDecline(vehicleId, nowMs, byEta)) { return } val boardingStop = boardingIndex @@ -696,14 +727,14 @@ internal class TripRideDetector( BOARDING_STOP_RADIUS_METERS } if (pendingCheckSinceMs != 0L) { - if (coveredByThePendingQuestion(vehicleId, nowMs)) { + if (coveredByThePendingQuestion(vehicleId, nowMs, byEta)) { return } // Another bus has left with the question unanswered. It joins what // the question is about rather than taking it over: the rider may // be on either, and "yes" will not say which. pendingDepartures.removeAll { nowMs - it.atMs > BOARDING_CHECK_MAX_AGE_MS } - pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = vehicleId == null) + pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = byEta) if (userWasAtStop && nowMs - pendingCheckSinceMs >= DEPARTURE_DEDUPE_MS) { // Long enough after the last time of asking to ask afresh. pendingCheckSinceMs = nowMs @@ -716,7 +747,7 @@ internal class TripRideDetector( } pendingCheckSinceMs = nowMs pendingDepartures.clear() - pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = vehicleId == null) + pendingDepartures += Departure(nowMs, vehicleId, reportedByEta = byEta) pendingCheckAnchor = recentUser.lastOrNull() } @@ -724,20 +755,37 @@ internal class TripRideDetector( * Whether this report is of a departure the rider's last "not yet" already * covered, reaching the app a second time. */ - private fun answeredByTheLastDecline(vehicleId: String?, nowMs: Long): Boolean { + private fun answeredByTheLastDecline(vehicleId: String?, nowMs: Long, byEta: Boolean): Boolean { // The window runs from the departure, not from the answer: a rider may // take minutes to reply, and what leaves the stop after that is the // next bus. declinedDepartures.removeAll { nowMs - it.atMs >= DEPARTURE_DEDUPE_MS } if (vehicleId == null) { - // The ETA catching up with what the rider has just answered for. - return declinedDepartures.isNotEmpty() || - (declinedRideAtMs != 0L && nowMs - declinedRideAtMs < DEPARTURE_DEDUPE_MS) + // The ETA catching up with a bus the rider has answered for. Each + // bus rolls the ETA over once, so each is caught up with once: a + // rollover beyond that is another bus leaving. + val awaited = declinedDepartures.firstOrNull { !it.reportedByEta } + if (awaited != null) { + awaited.reportedByEta = true + return true + } + if (declinedRideAtMs != 0L && nowMs - declinedRideAtMs < DEPARTURE_DEDUPE_MS) { + declinedRideAtMs = 0L + return true + } + return false + } + if (byEta) { + // A second rollover, with a plate: not the echo of the first. + return false } // The question came from the ETA alone, so the answer had no vehicle to - // be held against. This is that bus, now named. Any other vehicle this - // close behind is a second bus, and gets a question of its own. + // be held against. This is that bus, now named — unless it was still + // at the stop after that one had gone. val unnamed = declinedDepartures.firstOrNull { it.vehicleId == null } ?: return false + if (wasAtTheStopAfter(vehicleId, unnamed.atMs)) { + return false + } unnamed.vehicleId = vehicleId rejectedAt[vehicleId] = lastDeclineAtMs return true @@ -747,23 +795,50 @@ internal class TripRideDetector( * Whether this report is the other feed's account, or a repeat, of a * departure the pending question already covers. */ - private fun coveredByThePendingQuestion(vehicleId: String?, nowMs: Long): Boolean { - if (vehicleId != null && pendingDepartures.any { it.vehicleId == vehicleId }) { - return true + private fun coveredByThePendingQuestion(vehicleId: String?, nowMs: Long, byEta: Boolean): Boolean { + if (vehicleId != null) { + val same = pendingDepartures.firstOrNull { it.vehicleId == vehicleId } + if (same != null) { + same.reportedByEta = same.reportedByEta || byEta + return true + } } val recent = pendingDepartures.filter { nowMs - it.atMs < DEPARTURE_DEDUPE_MS } if (vehicleId == null) { - // The ETA's account of a bus the position feed has reported. + // A rollover naming nothing, beside a bus the position feed saw + // leave: probably that bus, possibly another. val unreported = recent.firstOrNull { !it.reportedByEta } ?: return false unreported.reportedByEta = true + unreported.mayBeTwoBuses = true return true } - // The position feed naming a bus only the ETA had reported. + if (byEta) { + return false + } + // The position feed reporting a bus beside a rollover that named + // nothing. One still at the stop after the rollover is another bus. val unnamed = recent.firstOrNull { it.vehicleId == null } ?: return false + if (wasAtTheStopAfter(vehicleId, unnamed.atMs)) { + return false + } unnamed.vehicleId = vehicleId + unnamed.mayBeTwoBuses = true return true } + /** Whether [vehicleId] reported itself at or short of the boarding stop later than [atMs]. */ + private fun wasAtTheStopAfter(vehicleId: String, atMs: Long): Boolean { + val boardingStop = boardingIndex + if (boardingStop < 0) { + return false + } + val boardingAlong = line.alongOfStop(boardingStop) + return busTracks[vehicleId].orEmpty().any { sample -> + sample.samePath && sample.onRoute && sample.gpsTimeMs > atMs && + sample.alongMeters - boardingAlong <= AT_STOP_MARGIN_METERS + } + } + private fun resolveBoardingPromptDue( nowMs: Long, usableUser: UserSample?, @@ -1015,13 +1090,16 @@ internal class TripRideDetector( ) /** - * One bus leaving the boarding stop. [vehicleId] is set once the position - * feed has reported it, [reportedByEta] once the ETA rolling over has. + * One bus leaving the boarding stop. [vehicleId] is set once a feed has + * named it, [reportedByEta] once the ETA rolling over has reported it. + * [mayBeTwoBuses] marks a position report and an unnamed rollover taken + * together without anything to show they were the same bus. */ private class Departure( val atMs: Long, var vehicleId: String?, var reportedByEta: Boolean, + var mayBeTwoBuses: Boolean = false, ) private data class LiveVehicle( diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt index 22c4fc9..a6858a6 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/RouteTripMonitorServiceTest.kt @@ -659,6 +659,46 @@ class RouteTripMonitorServiceTest { assertNull(AppRuntimeStateStore.loadPausedTripMonitor(service)) } + @Test + fun theBusTheStopFeedPlacesAtTheStopIsTheOneItsRolloverReports() { + val trip = threeStopSession() + mapOf( + "routeId" to "TXG1", "boardingStopId" to 1, "destinationStopId" to 3, + ) + // A stands at the boarding stop, by both feeds; then it leaves, by both. + var busStop = stopsOf(trip).first() + var gpsTime = nowSeconds() - 60 + var stopFeed = stopFeed(eta = 0, plate = "BUS-A") + backend = { path -> + if (path.endsWith("/realtime/buses")) { + busFeed("BUS-A", busStop, pathId = 0, gpsTimeSeconds = gpsTime) + } else { + stopFeed + } + } + service.onStartCommand(startIntent(trip), 0, 1) + refreshAndDeliverAlerts() + busStop = stopsOf(trip)[1] + gpsTime += 20 + stopFeed = stopFeed(eta = 600, plate = "BBB-002") + ShadowSystemClock.advanceBy(25, TimeUnit.SECONDS) + refreshAndDeliverAlerts() + assertTrue(ride(service).boardingCheckId != 0L) + + ReflectionHelpers.setField( + ReflectionHelpers.getField(service, "detector"), + "pendingCheckSinceMs", + System.currentTimeMillis() - 45_000L, + ) + refreshAndDeliverAlerts() + service.onStartCommand(buttonIntent(notificationId = 6023, title = "我已上車"), 0, 2) + + assertEquals( + "the rollover was tied to A by its plate, so it is no second bus", + "BUS-A", + ride(service).trackedVehicleId, + ) + } + @Test fun aFailingStopFeedDoesNotSilenceTheVehicleFeed() { val requests = java.util.concurrent.CopyOnWriteArrayList() diff --git a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt index 99e934d..5dbbc2e 100644 --- a/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt +++ b/android/app/src/test/kotlin/tw/avianjay/taiwanbus/flutter/TripRideDetectorManualBoardingTest.kt @@ -551,28 +551,104 @@ class TripRideDetectorManualBoardingTest { } @Test - fun theEtaCatchingUpWithABusAlreadySeenLeavingIsNotASecondBus() { - for (aSecondBusLeaves in listOf(false, true)) { + fun aRolloverBesideABusSeenLeavingIsThatBusOnlyWhenTheFeedSaysSo() { + // A is seen at the stop and leaving. Within the minute the ETA rolls + // over. [atStop] is the bus the stop feed placed there, if it said. + fun answer(atStop: String?, aSecondRollover: Boolean = false): RideSnapshot { val detector = newDetector() detector.onBusReports(listOf(report(0, along = 0.0))) - detector.onBoardingStopEta(etaSeconds = 0, arriving = true, nowMs = at(0)) + detector.onBoardingStopEta(0, arriving = true, nowMs = at(0), arrivingVehicleId = atStop) detector.snapshot(at(0)) detector.onBusReports(listOf(report(20, along = 150.0))) val asked = detector.snapshot(at(20)).boardingCheckId assertTrue(asked != 0L) - // Half a minute on the ETA rolls over to the next bus: the same - // departure, from the other feed. - detector.onBoardingStopEta(etaSeconds = 600, arriving = false, nowMs = at(50)) - if (aSecondBusLeaves) { - // Then it does so again. That bus has no position feed, and the - // rider may be on it. - detector.onBoardingStopEta(etaSeconds = 0, arriving = true, nowMs = at(60)) - detector.onBoardingStopEta(etaSeconds = 600, arriving = false, nowMs = at(80)) + detector.onBoardingStopEta(600, arriving = false, nowMs = at(50)) + if (aSecondRollover) { + detector.onBoardingStopEta(0, arriving = true, nowMs = at(60), arrivingVehicleId = atStop) + detector.onBoardingStopEta(600, arriving = false, nowMs = at(80)) } + detector.onBusReports(listOf(report(80, along = 900.0))) + val ride = detector.snapshot(at(84)) + assertEquals("the rollover is not asked about separately", asked, ride.boardingCheckId) detector.confirmBoardedByUser(at(85), answeredCheckId = asked) + return detector.snapshot(at(85)) + } + // The feed named the bus at the stop: the rollover is A leaving. + assertEquals(BUS, answer(atStop = BUS).trackedVehicleId) + assertEquals(BUS, answer(atStop = BUS, aSecondRollover = true).trackedVehicleId) + // It named another: B, with no position feed, left as well. + assertUnidentified(answer(atStop = "BUS-B")) + // It named none. The rider may have boarded a B the app never saw. + assertUnidentified(answer(atStop = null)) + assertUnidentified(answer(atStop = null, aSecondRollover = true)) + } - val ride = detector.snapshot(at(85)) - if (aSecondBusLeaves) assertUnidentified(ride) else assertEquals(BUS, ride.trackedVehicleId) + @Test + fun aBusSeenLeavingBesideAnUnnamedRolloverIsNotNamedByTheAnswer() { + // The other order: the rollover first, then the position feed. + val detector = newDetector() + detector.onBusReports(listOf(report(0, along = 0.0))) + detector.onBoardingStopEta(0, arriving = true, nowMs = at(0)) + detector.snapshot(at(0)) + detector.onBoardingStopEta(600, arriving = false, nowMs = at(15)) + val asked = detector.snapshot(at(15)).boardingCheckId + assertTrue(asked != 0L) + detector.onBusReports(listOf(report(10, along = 150.0).copy(receivedAtMs = at(30)))) + detector.snapshot(at(30)) + detector.onBusReports(listOf(report(60, along = 800.0))) + assertEquals(asked, detector.snapshot(at(60)).boardingCheckId) + detector.confirmBoardedByUser(at(65), answeredCheckId = asked) + + assertUnidentified(detector.snapshot(at(65))) + } + + @Test + fun eachDeclinedBusIsCaughtUpWithByTheEtaOnce() { + // Two buses with no position feed, a "not yet" between them. + val etaOnly = newDetector() + etaOnly.onBoardingStopEta(0, arriving = true, nowMs = at(0)) + etaOnly.onBoardingStopEta(600, arriving = false, nowMs = at(30)) + val first = etaOnly.snapshot(at(75)) + assertTrue(first.boardingPromptDue) + etaOnly.declineBoarding(at(80), answeredCheckId = first.boardingCheckId) + etaOnly.onBoardingStopEta(0, arriving = true, nowMs = at(90)) + etaOnly.onBoardingStopEta(600, arriving = false, nowMs = at(110)) + assertTrue("the second bus is asked about", etaOnly.snapshot(at(110)).boardingCheckId != 0L) + + // A bus seen leaving and declined: its rollover is swallowed, the next is not. + val seen = newDetector() + seen.onBusReports(listOf(report(0, along = 0.0))) + seen.onBoardingStopEta(0, arriving = true, nowMs = at(0)) + seen.snapshot(at(0)) + seen.onBusReports(listOf(report(20, along = 150.0))) + val asked = seen.snapshot(at(20)).boardingCheckId + seen.onBusReports(listOf(report(60, along = 700.0))) + assertTrue(seen.snapshot(at(60)).boardingPromptDue) + seen.declineBoarding(at(65), answeredCheckId = asked) + seen.onBoardingStopEta(600, arriving = false, nowMs = at(70)) + assertEquals("A's own rollover", 0L, seen.snapshot(at(70)).boardingCheckId) + seen.onBoardingStopEta(0, arriving = true, nowMs = at(80)) + seen.onBoardingStopEta(600, arriving = false, nowMs = at(100)) + assertTrue("the next bus's rollover", seen.snapshot(at(100)).boardingCheckId != 0L) + } + + @Test + fun aBusStillAtTheStopAfterADeclinedRolloverIsTheNextBus() { + for (stillThereAfterwards in listOf(false, true)) { + val detector = newDetector() + detector.onBoardingStopEta(0, arriving = true, nowMs = at(0)) + detector.onBoardingStopEta(600, arriving = false, nowMs = at(30)) + val first = detector.snapshot(at(75)) + assertTrue(first.boardingPromptDue) + detector.declineBoarding(at(80), answeredCheckId = first.boardingCheckId) + // The position feed, late: at the stop either before the rollover + // (the same bus) or after it (the one behind). + val atStopAt = if (stillThereAfterwards) 60L else 10L + detector.onBusReports(listOf(report(atStopAt, along = 0.0).copy(receivedAtMs = at(85)))) + detector.snapshot(at(85)) + detector.onBusReports(listOf(report(95, along = 150.0))) + + assertEquals(stillThereAfterwards, detector.snapshot(at(100)).boardingCheckId != 0L) } }