Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions app/src/main/kotlin/de/traewelling/app/data/dbf/DbfJsonParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package de.traewelling.app.data.dbf

import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.Strictness
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import java.io.StringReader
import java.time.Instant
import java.time.LocalTime

/** The documented IRIS JSON v3 branch of derf/db-fakedisplay's Stationboard renderer. */
object DbfJsonParser {
private const val MAX_JSON_CHARS = 2 * 1024 * 1024
private const val MAX_DEPARTURES = 5_000
private val timePattern = Regex("(?:[01]\\d|2[0-3]):[0-5]\\d")
private val integerPattern = Regex("-?(?:0|[1-9]\\d*)")

/** Throw on a failed or unexpected response instead of treating it as an empty board. */
fun parse(json: String, eva: String, fetchedAt: Instant = Instant.now()): DbfBoard {
require(normalizedEva(eva) != null) { "Invalid DBF station EVA" }
require(json.length <= MAX_JSON_CHARS) { "DBF response is too large" }
val reader = JsonReader(StringReader(json)).apply { strictness = Strictness.STRICT }
val root = reader.use {
val parsed = JsonParser.parseReader(it)
require(it.peek() == JsonToken.END_DOCUMENT) { "Trailing data in DBF response" }
parsed
}
require(root.isJsonObject) { "Expected a DBF station board" }
val objectRoot = root.asJsonObject
require(!objectRoot.has("error")) { "DBF returned an error" }
val rows = objectRoot.get("departures")
require(rows != null && rows.isJsonArray) { "DBF departures are missing" }
require(rows.asJsonArray.size() <= MAX_DEPARTURES) { "Too many DBF departures" }
val departures = rows.asJsonArray.map { row ->
require(row.isJsonObject) { "Invalid DBF departure" }
val obj = row.asJsonObject
// HAFAS and other backends also expose JSON, but use epoch fields instead.
require(obj.has("scheduledArrival") && obj.has("scheduledDeparture") &&
obj.has("delayArrival") && obj.has("delayDeparture")) { "Expected DBF IRIS JSON v3" }
DbfDeparture(
trainNumber = obj.string("trainNumber"),
scheduledArrival = obj.time("scheduledArrival"),
scheduledDeparture = obj.time("scheduledDeparture"),
delayArrival = obj.integer("delayArrival"),
delayDeparture = obj.integer("delayDeparture"),
platform = obj.string("platform"),
scheduledPlatform = obj.string("scheduledPlatform"),
isCancelled = obj.booleanOrFlag("isCancelled"),
missingRealtime = obj.booleanOrFlag("missingRealtime"),
messages = obj.messages()
)
}
return DbfBoard(eva.trim(), departures.toList(), fetchedAt)
}

internal fun normalizedEva(value: String?): String? = value?.trim()
?.takeIf { it.length == 7 && it.all(Char::isDigit) && it.toLongOrNull() != 0L }

private fun JsonObject.string(key: String): String? = get(key)?.takeUnless(JsonElement::isJsonNull)
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
?.asString?.trim()?.takeIf(String::isNotEmpty)

private fun JsonObject.time(key: String): LocalTime? = string(key)?.takeIf(timePattern::matches)
?.let { runCatching { LocalTime.parse(it) }.getOrNull() }

private fun JsonObject.integer(key: String): Int? {
val value = get(key)?.takeUnless(JsonElement::isJsonNull) ?: return null
if (!value.isJsonPrimitive || !value.asJsonPrimitive.isNumber) return null
return value.asString.takeIf(integerPattern::matches)?.toIntOrNull()
}

private fun JsonObject.booleanOrFlag(key: String): Boolean? {
val value = get(key)?.takeUnless(JsonElement::isJsonNull) ?: return null
if (!value.isJsonPrimitive) return null
val primitive = value.asJsonPrimitive
if (primitive.isBoolean) return primitive.asBoolean
if (primitive.isNumber) return when (primitive.asString) { "0" -> false; "1" -> true; else -> null }
return null
}

private fun JsonObject.messages(): List<DbfMessage> {
val groups = get("messages")?.takeIf(JsonElement::isJsonObject)?.asJsonObject ?: return emptyList()
return listOf("delay", "qos").flatMap { group ->
groups.get(group)?.takeIf(JsonElement::isJsonArray)?.asJsonArray?.mapNotNull { raw ->
val item = raw.takeIf(JsonElement::isJsonObject)?.asJsonObject ?: return@mapNotNull null
val text = item.string("text") ?: return@mapNotNull null
// This is a message creation time; it must not be attached to predictions.
DbfMessage(text, item.string("timestamp"))
} ?: emptyList()
}
}
}
98 changes: 98 additions & 0 deletions app/src/main/kotlin/de/traewelling/app/data/dbf/DbfMatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package de.traewelling.app.data.dbf

import java.time.Duration
import java.time.Instant
import java.time.LocalTime
import java.time.ZoneId

/** Match only the current board window, operational train number and planned event. */
object DbfMatcher {
private val zone = ZoneId.of("Europe/Berlin")
// The repository requests past=1. Restrict matching to a nearby actual event
// window without guessing a date for the source's date-less HH:mm fields.
private val lookBehind = Duration.ofMinutes(60)
private val lookAhead = Duration.ofMinutes(180)

fun match(
board: DbfBoard,
eva: String,
journeyNumber: String?,
arrivalPlanned: Instant?,
departurePlanned: Instant?
): DbfStopDelta? {
val station = DbfJsonParser.normalizedEva(eva) ?: return null
if (station != board.eva) return null
val number = trainNumber(journeyNumber) ?: return null
val rows = board.departures.filter { trainNumber(it.trainNumber) == number }
val arrivals = rows.filter {
matchesTime(it.scheduledArrival, arrivalPlanned, board.fetchedAt, it.delayArrival, it.missingRealtime)
}
val departures = rows.filter {
matchesTime(it.scheduledDeparture, departurePlanned, board.fetchedAt, it.delayDeparture, it.missingRealtime)
}
if (arrivals.size > 1 || departures.size > 1) return null
val arrivalRow = arrivals.singleOrNull()
val departureRow = departures.singleOrNull()
if (arrivalRow == null && departureRow == null) return null
// Equality is not identity: equal duplicates must have been rejected above as well.
if (arrivalRow != null && departureRow != null && arrivalRow !== departureRow) return null
val row = arrivalRow ?: departureRow ?: return null
val allRequestedEventsMatched = (arrivalPlanned == null || arrivalRow != null) &&
(departurePlanned == null || departureRow != null)
val cancelled: DbfField<Boolean> = when {
!allRequestedEventsMatched -> DbfField.Absent
row.isCancelled == true -> DbfField.Present(true)
// False also includes partial cancellation; v3 cannot certify restoration.
else -> DbfField.Absent
}
return DbfStopDelta(
arrival = arrivalRow?.let {
eventDelta(it, arrivalPlanned!!, it.delayArrival, it.scheduledDeparture == null)
},
departure = departureRow?.let { eventDelta(it, departurePlanned!!, it.delayDeparture, true) },
cancelled = cancelled,
fetchedAt = board.fetchedAt
)
}

private fun matchesTime(
time: LocalTime?, planned: Instant?, fetchedAt: Instant, delay: Int?, missingRealtime: Boolean?
): Boolean {
if (time == null || planned == null) return false
// A substantially delayed train can have a planned time outside this window,
// but a confirmed nonzero delay brings its actual event into the current board.
// Bound the delay to avoid matching an occurrence from another service day.
val relevant = if (missingRealtime == false && delay != null && delay != 0 && delay in -180..720) {
planned.plusSeconds(delay.toLong() * 60)
} else planned
if (relevant < fetchedAt.minus(lookBehind) || relevant > fetchedAt.plus(lookAhead)) return false
val local = planned.atZone(zone).toLocalDateTime()
// DBF HH:mm cannot distinguish either occurrence of the repeated autumn hour.
if (zone.rules.getValidOffsets(local).size != 1) return false
return local.toLocalTime().withSecond(0).withNano(0) == time
}

private fun eventDelta(row: DbfDeparture, planned: Instant, delay: Int?, usePlatform: Boolean): DbfEventDelta {
if (row.missingRealtime != false || row.isCancelled == true) return DbfEventDelta()
// DBF also calculates 0 from a planned-time fallback. Never erase a known delay
// merely because its v3 representation cannot prove realtime punctuality.
val actual = delay?.takeIf { it != 0 && it in -180..720 }
?.let { planned.plusSeconds(it.toLong() * 60) }
val changedPlatform = if (usePlatform) changedPlatform(row) else null
return DbfEventDelta(
realTime = actual?.let { DbfField.Present(it) } ?: DbfField.Absent,
// v3 collapses the event platforms into one value, preferring departure.
platform = changedPlatform?.let { DbfField.Present(it) } ?: DbfField.Absent,
scheduledPlatform = if (changedPlatform != null) DbfField.Present(row.scheduledPlatform)
else DbfField.Absent
)
}

private fun changedPlatform(row: DbfDeparture): String? = row.platform?.takeIf {
row.scheduledPlatform != null && it != row.scheduledPlatform
}

private fun trainNumber(value: String?): Long? = value?.trim()
?.takeIf { it.isNotEmpty() && it.all(Char::isDigit) }
?.toLongOrNull()?.takeIf { it > 0 }
}
53 changes: 53 additions & 0 deletions app/src/main/kotlin/de/traewelling/app/data/dbf/DbfModels.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package de.traewelling.app.data.dbf

import java.time.Instant
import java.time.LocalTime

/** A missing or null source field must never manufacture a punctual prediction. */
sealed interface DbfField<out T> {
data object Absent : DbfField<Nothing>
data class Present<T>(val value: T?) : DbfField<T>
}

data class DbfMessage(val text: String, val timestamp: String?)

data class DbfDeparture(
val trainNumber: String?,
val scheduledArrival: LocalTime?,
val scheduledDeparture: LocalTime?,
val delayArrival: Int?,
val delayDeparture: Int?,
val platform: String?,
val scheduledPlatform: String?,
val isCancelled: Boolean?,
val missingRealtime: Boolean?,
val messages: List<DbfMessage> = emptyList()
)

/**
* DBF v3 supplies neither station/date identifiers nor a prediction update timestamp.
* eva is the exact requested station, with related stations disabled in the request.
* fetchedAt records retrieval only, and is not the provider's update time.
*/
data class DbfBoard(
val eva: String,
val departures: List<DbfDeparture>,
val fetchedAt: Instant,
val providerUpdatedAt: Instant? = null
)

data class DbfEventDelta(
val realTime: DbfField<Instant> = DbfField.Absent,
val platform: DbfField<String> = DbfField.Absent,
// Apply together with platform so the UI compares the same provider's pair.
val scheduledPlatform: DbfField<String> = DbfField.Absent,
val providerUpdatedAt: Instant? = null
)

data class DbfStopDelta(
val arrival: DbfEventDelta?,
val departure: DbfEventDelta?,
val cancelled: DbfField<Boolean>,
val fetchedAt: Instant,
val providerUpdatedAt: Instant? = null
)
Loading
Loading