|
| 1 | +/* |
| 2 | + * Copyright (C) 2010-2023, Danilo Pianini and contributors |
| 3 | + * listed, for each module, in the respective subproject's build.gradle.kts file. |
| 4 | + * |
| 5 | + * This file is part of Alchemist, and is distributed under the terms of the |
| 6 | + * GNU General Public License, with a linking exception, |
| 7 | + * as described in the file LICENSE in the Alchemist distribution's top directory. |
| 8 | + */ |
| 9 | + |
| 10 | +package it.unibo.alchemist.boundary.gps.loaders.ais |
| 11 | + |
| 12 | +import dk.dma.ais.binary.SixbitException |
| 13 | +import dk.dma.ais.message.AisMessage |
| 14 | +import dk.dma.ais.message.AisMessageException |
| 15 | +import dk.dma.ais.message.UTCDateResponseMessage |
| 16 | +import dk.dma.ais.sentence.SentenceException |
| 17 | +import dk.dma.ais.sentence.Vdm |
| 18 | +import java.io.File |
| 19 | +import java.time.LocalDate |
| 20 | +import kotlin.time.Instant |
| 21 | +import org.slf4j.LoggerFactory |
| 22 | + |
| 23 | +/** |
| 24 | + * Utility object to decode AIS raw messages. |
| 25 | + */ |
| 26 | +object AISDecoder { |
| 27 | + private const val DATE_TIME_PREFIX = "!DATE-TIME," |
| 28 | + private const val DATE_TIME_SEPARATOR = 'T' |
| 29 | + private const val FALLBACK_DATE = "1970-01-01" |
| 30 | + private const val INVALID_CHECKSUM = "Invalid checksum" |
| 31 | + private const val OUT_OF_SEQUENCE_SENTENCE = "Out of sequence sentence:" |
| 32 | + |
| 33 | + private val logger = LoggerFactory.getLogger(AISDecoder::class.java) |
| 34 | + |
| 35 | + /** Parses all the raw AIS lines contained in a [File]. |
| 36 | + * @param file the [File] from which parse AIS info. |
| 37 | + **/ |
| 38 | + fun parseFile(file: File): List<Pair<Instant, AisMessage>> = parsePayload(file.readText(Charsets.UTF_8)) |
| 39 | + |
| 40 | + /** |
| 41 | + * @return the messages parsed from a raw [String] to [AisMessage], paired with timestamps obtained from AIS |
| 42 | + * source tags, AIS UTC response messages, or in-file `!DATE-TIME` markers when no AIS timestamp is available. |
| 43 | + **/ |
| 44 | + fun parsePayload(payload: String): List<Pair<Instant, AisMessage>> = |
| 45 | + parsePayload(payload, startOfDay(FALLBACK_DATE)) |
| 46 | + |
| 47 | + /** |
| 48 | + * @param date the payload date as an instant. Messages preceding the first explicit timestamp use this instant. |
| 49 | + * @return the message parsed from a raw [String] to [AisMessage] and maps it to the timestamp of the raw message. |
| 50 | + **/ |
| 51 | + fun parsePayload(payload: String, date: Instant): List<Pair<Instant, AisMessage>> { |
| 52 | + var payloadDate = LocalDate.parse(date.toIsoDate()) |
| 53 | + var currentTimestamp = date |
| 54 | + var vdmAccumulator = Vdm() |
| 55 | + return buildList { |
| 56 | + for (line in payload.lines()) { |
| 57 | + when { |
| 58 | + line.startsWith(DATE_TIME_PREFIX) -> { |
| 59 | + val time = line.substringAfter(DATE_TIME_PREFIX).trim() |
| 60 | + var timestamp = Instant.parse("${payloadDate}T${time}Z") |
| 61 | + if (timestamp < currentTimestamp) { |
| 62 | + payloadDate = payloadDate.plusDays(1) |
| 63 | + timestamp = Instant.parse("${payloadDate}T${time}Z") |
| 64 | + } |
| 65 | + currentTimestamp = timestamp |
| 66 | + } |
| 67 | + line.isBlank() -> Unit |
| 68 | + else -> vdmAccumulator = vdmAccumulator.parseSentence(line) { message -> |
| 69 | + val messageTimestamp = message.timestamp ?: currentTimestamp |
| 70 | + currentTimestamp = messageTimestamp |
| 71 | + add(messageTimestamp to message) |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + private fun startOfDay(date: String): Instant = Instant.parse("${date}T00:00:00Z") |
| 79 | + |
| 80 | + private fun Instant.toIsoDate(): String = toString().substringBefore(DATE_TIME_SEPARATOR) |
| 81 | + |
| 82 | + private val AisMessage.timestamp: Instant? |
| 83 | + get() = sourceTag?.timestamp?.time?.let(Instant::fromEpochMilliseconds) |
| 84 | + ?: (this as? UTCDateResponseMessage)?.date?.time?.let(Instant::fromEpochMilliseconds) |
| 85 | + |
| 86 | + private val SentenceException.hasInvalidChecksum: Boolean |
| 87 | + get() = message?.contains(INVALID_CHECKSUM) == true |
| 88 | + |
| 89 | + private val SentenceException.isOutOfSequence: Boolean |
| 90 | + get() = message?.contains(OUT_OF_SEQUENCE_SENTENCE) == true |
| 91 | + |
| 92 | + private val SentenceException.isRecoverable: Boolean |
| 93 | + get() = isOutOfSequence || hasInvalidChecksum |
| 94 | + |
| 95 | + private fun Vdm.parseSentence(line: String, onComplete: (AisMessage) -> Unit): Vdm { |
| 96 | + val vdm = runCatching { apply { parse(line) } }.getOrElse { exception -> |
| 97 | + if (exception is SentenceException && exception.isRecoverable) { |
| 98 | + logger.debug( |
| 99 | + "Resetting partial AIS message after an out-of-sequence sentence or invalid checksum", |
| 100 | + exception, |
| 101 | + ) |
| 102 | + return Vdm() |
| 103 | + } else { |
| 104 | + throw exception |
| 105 | + } |
| 106 | + } |
| 107 | + return if (vdm.isCompletePacket) { |
| 108 | + vdm.toAisMessage()?.let(onComplete) |
| 109 | + Vdm() |
| 110 | + } else { |
| 111 | + vdm |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + private val Throwable.isUndecodableMessage: Boolean |
| 116 | + get() = this is AisMessageException || this is SixbitException |
| 117 | + |
| 118 | + private fun Vdm.toAisMessage(): AisMessage? = runCatching { |
| 119 | + AisMessage.getInstance(this) |
| 120 | + }.getOrElse { exception -> |
| 121 | + if (exception.isUndecodableMessage) { |
| 122 | + logger.debug("Discarding undecodable AIS message", exception) |
| 123 | + null |
| 124 | + } else { |
| 125 | + throw exception |
| 126 | + } |
| 127 | + } |
| 128 | +} |
0 commit comments