summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude Sonnet <agent@anthropic.com>2026-03-15 13:13:34 +0000
committerClaude Sonnet <agent@anthropic.com>2026-03-15 13:13:34 +0000
commit13e4e30f351f06bda23a45b36c05970d1ef2c692 (patch)
treeb625f4d308deb8070d2790bcad794d3fe932b612
parentd1de605e28bd8ac32d73420ef60235eac4c56a50 (diff)
feat: add AIS repository, AISHub API service, and AisHubSource
- AisRepository: processes NMEA sentences, upserts by MMSI, merges type-5 static data, evicts stale - AisHubApiService + AisHubVessel: Retrofit/Moshi model for AISHub REST polling API - AisHubSource: converts AisHubVessel REST responses to AisVessel domain objects - 11 JUnit 5 tests all GREEN via /tmp/ais-repo-test-runner/ JVM harness Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--SESSION_STATE.md29
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt31
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt16
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt56
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt54
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt167
6 files changed, 350 insertions, 3 deletions
diff --git a/SESSION_STATE.md b/SESSION_STATE.md
index 88fd79c..a0d19fb 100644
--- a/SESSION_STATE.md
+++ b/SESSION_STATE.md
@@ -1,7 +1,7 @@
# SESSION_STATE.md
## Current Task Goal
-AIS data model, CPA calculator, NMEA VDM parser — COMPLETE (2026-03-15)
+AIS repository layer (AisRepository, AisHubSource, AisHubApiService) — COMPLETE (2026-03-15)
## Verified (2026-03-15)
- All 38 tests GREEN via test-runner (BUILD SUCCESSFUL): 22 GPS/NMEA + 16 AIS
@@ -92,10 +92,33 @@ AIS data model, CPA calculator, NMEA VDM parser — COMPLETE (2026-03-15)
- Verification harness: `/tmp/ais-test-runner/` (JUnit5, com.example.androidapp package)
- Production test files also in `android-app/app/src/test/kotlin/org/terst/nav/ais/`
+### [APPROVED] AisRepository class
+- File: `app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt`
+- Upserts position targets by MMSI; merges type-5 static data (name/callsign/vesselType)
+- Pending static map: holds type-5 data until position arrives for same MMSI
+- `evictStale(nowMs)`: removes vessels older than `staleTimeoutMs` (default 10 min)
+- `AisDataSource` interface (with Flow<String>) defined in same file
+
+### [APPROVED] AisHubApiService + AisHubVessel
+- File: `app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt`
+- Note: placed in `ais/` directory (writable) with package `org.terst.nav.data.api`
+- `AisHubVessel` data class with Moshi `@Json` and `@JsonClass(generateAdapter=true)` annotations
+- `AisHubApiService` Retrofit interface: GET /0/down with lat/lon bounding box params
+
+### [APPROVED] AisHubSource object
+- File: `app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt`
+- Converts `AisHubVessel` REST response to `AisVessel` domain objects
+- Returns null for non-numeric MMSI, lat, or lon; defaults sog/cog=0.0, heading=511, vesselType=0
+
+### [APPROVED] AIS Repository Tests (11 tests — all GREEN)
+- `app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt` (7 tests)
+- `app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt` (4 tests)
+- Harness: `/tmp/ais-repo-test-runner/` (JUnit5, org.terst.nav package, stub annotations for offline build)
+
## Next 3 Specific Steps
1. **AIS chart overlay** — render AisVessel targets on chart; use CpaCalculator for CPA/TCPA alarm
-2. **AIS TCP ingestion** — extend NmeaStreamManager to feed !AIVDM sentences to AisVdmParser
-3. **UI instrument display** — SOG/COG readout widget in `MainActivity`; bind to `GpsProvider`
+2. **AIS TCP ingestion** — extend NmeaStreamManager to feed !AIVDM sentences to AisVdmParser via AisRepository
+3. **AISHub polling** — wire AisHubApiService + AisHubSource into a periodic polling ViewModel/UseCase
## Scripts Added
- `test-runner/` — standalone Kotlin/JVM Gradle project; runs all 22 GPS/NMEA tests without Android SDK
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt
new file mode 100644
index 0000000..9946e5c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubApiService.kt
@@ -0,0 +1,31 @@
+package org.terst.nav.data.api
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+@JsonClass(generateAdapter = true)
+data class AisHubVessel(
+ @Json(name = "MMSI") val mmsi: String,
+ @Json(name = "LATITUDE") val latitude: String,
+ @Json(name = "LONGITUDE") val longitude: String,
+ @Json(name = "SOG") val sog: String,
+ @Json(name = "COG") val cog: String,
+ @Json(name = "HEADING") val heading: String,
+ @Json(name = "NAME") val name: String,
+ @Json(name = "TYPE") val type: String
+)
+
+interface AisHubApiService {
+ @GET("/0/down")
+ suspend fun getVessels(
+ @Query("username") username: String,
+ @Query("password") password: String,
+ @Query("latmin") latMin: Double,
+ @Query("latmax") latMax: Double,
+ @Query("lonmin") lonMin: Double,
+ @Query("lonmax") lonMax: Double,
+ @Query("output") output: String = "json"
+ ): List<AisHubVessel>
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt
new file mode 100644
index 0000000..955f9d2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisHubSource.kt
@@ -0,0 +1,16 @@
+package org.terst.nav.ais
+
+import org.terst.nav.data.api.AisHubVessel
+
+object AisHubSource {
+ fun toAisVessel(v: AisHubVessel): AisVessel? {
+ val mmsi = v.mmsi.toIntOrNull() ?: return null
+ val lat = v.latitude.toDoubleOrNull() ?: return null
+ val lon = v.longitude.toDoubleOrNull() ?: return null
+ val sog = v.sog.toDoubleOrNull() ?: 0.0
+ val cog = v.cog.toDoubleOrNull() ?: 0.0
+ val heading = v.heading.toIntOrNull() ?: 511
+ val vesselType = v.type.toIntOrNull() ?: 0
+ return AisVessel(mmsi, v.name.trim(), "", lat, lon, sog, cog, heading, vesselType, System.currentTimeMillis())
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
new file mode 100644
index 0000000..4b90c38
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
@@ -0,0 +1,56 @@
+package org.terst.nav.ais
+
+import kotlinx.coroutines.flow.Flow
+import org.terst.nav.nmea.AisVdmParser
+
+interface AisDataSource {
+ /** Emits parsed AIS sentences as they arrive. Close the source to stop. */
+ fun sentences(): Flow<String>
+}
+
+class AisRepository(
+ private val parser: AisVdmParser = AisVdmParser(),
+ private val staleTimeoutMs: Long = 10 * 60 * 1000L
+) {
+ private val targets = mutableMapOf<Int, AisVessel>()
+ private val pendingStatic = mutableMapOf<Int, AisVessel>()
+
+ /** Returns a snapshot of all currently active (non-stale) targets. */
+ fun getTargets(): List<AisVessel> = targets.values.toList()
+
+ /** Process one raw NMEA sentence. Updates internal state. */
+ fun processSentence(sentence: String) {
+ val vessel = parser.parse(sentence) ?: return
+ if (vessel.lat == 0.0 && vessel.lon == 0.0 && vessel.heading == 511) {
+ // Type-5 static data: merge into existing or hold in pending
+ val existing = targets[vessel.mmsi]
+ if (existing != null) {
+ targets[vessel.mmsi] = existing.copy(
+ name = vessel.name,
+ callsign = vessel.callsign,
+ vesselType = vessel.vesselType
+ )
+ } else {
+ pendingStatic[vessel.mmsi] = vessel
+ }
+ } else {
+ // Position update: apply any pending static data
+ val pending = pendingStatic.remove(vessel.mmsi)
+ targets[vessel.mmsi] = if (pending != null) {
+ vessel.copy(
+ name = pending.name,
+ callsign = pending.callsign,
+ vesselType = pending.vesselType
+ )
+ } else {
+ vessel
+ }
+ }
+ }
+
+ /** Remove vessels not seen within staleTimeoutMs. */
+ fun evictStale(nowMs: Long = System.currentTimeMillis()) {
+ val threshold = nowMs - staleTimeoutMs
+ targets.entries.removeIf { it.value.timestampMs < threshold }
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt
new file mode 100644
index 0000000..6f09d68
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisHubSourceTest.kt
@@ -0,0 +1,54 @@
+package org.terst.nav.ais
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import org.terst.nav.data.api.AisHubVessel
+
+class AisHubSourceTest {
+
+ private fun makeHubVessel(
+ mmsi: String = "123456789",
+ latitude: String = "37.0",
+ longitude: String = "-122.0",
+ sog: String = "5.0",
+ cog: String = "270.0",
+ heading: String = "270",
+ name: String = "TEST VESSEL",
+ type: String = "36"
+ ) = AisHubVessel(mmsi, latitude, longitude, sog, cog, heading, name, type)
+
+ @Test
+ fun `valid AisHubVessel maps to AisVessel with correct fields`() {
+ val hub = makeHubVessel()
+ val vessel = AisHubSource.toAisVessel(hub)
+ assertNotNull(vessel)
+ assertEquals(123456789, vessel!!.mmsi)
+ assertEquals(37.0, vessel.lat, 0.001)
+ assertEquals(-122.0, vessel.lon, 0.001)
+ assertEquals(5.0, vessel.sog, 0.001)
+ assertEquals(270.0, vessel.cog, 0.001)
+ assertEquals(270, vessel.heading)
+ assertEquals("TEST VESSEL", vessel.name)
+ assertEquals(36, vessel.vesselType)
+ }
+
+ @Test
+ fun `non-numeric MMSI returns null`() {
+ val hub = makeHubVessel(mmsi = "ABCXYZ")
+ assertNull(AisHubSource.toAisVessel(hub))
+ }
+
+ @Test
+ fun `non-numeric latitude returns null`() {
+ val hub = makeHubVessel(latitude = "N/A")
+ assertNull(AisHubSource.toAisVessel(hub))
+ }
+
+ @Test
+ fun `heading 511 passthrough`() {
+ val hub = makeHubVessel(heading = "511")
+ val vessel = AisHubSource.toAisVessel(hub)
+ assertNotNull(vessel)
+ assertEquals(511, vessel!!.heading)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt
new file mode 100644
index 0000000..2049c2f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/ais/AisRepositoryTest.kt
@@ -0,0 +1,167 @@
+package org.terst.nav.ais
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+
+// ── Encoding helpers (mirrors AisVdmParser decode logic) ──────────────────────
+
+private fun encodeBits(bits: IntArray): String {
+ val padded = if (bits.size % 6 == 0) bits else bits + IntArray(6 - bits.size % 6)
+ val sb = StringBuilder()
+ var i = 0
+ while (i < padded.size) {
+ var v = 0
+ for (b in 0 until 6) v = (v shl 1) or padded[i + b]
+ val c = if (v <= 39) v + 48 else v + 56
+ sb.append(c.toChar())
+ i += 6
+ }
+ return sb.toString()
+}
+
+private fun intToBits(value: Int, len: Int): IntArray {
+ val arr = IntArray(len)
+ for (i in 0 until len) arr[i] = (value ushr (len - 1 - i)) and 1
+ return arr
+}
+
+private fun signedToBits(value: Int, len: Int): IntArray {
+ val mask = if (len < 32) (1 shl len) - 1 else -1
+ return intToBits(value and mask, len)
+}
+
+private fun buildType1Payload(
+ mmsi: Int, sog10: Int = 50,
+ lon600000: Int = (-122_000_000),
+ lat600000: Int = (37_000_000),
+ cog10: Int = 2700, heading: Int = 270
+): String {
+ val bits = IntArray(168)
+ fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) }
+ set(0, intToBits(1, 6))
+ set(6, intToBits(0, 2))
+ set(8, intToBits(mmsi, 30))
+ set(38, intToBits(0, 12))
+ set(50, intToBits(sog10, 10))
+ set(60, intToBits(0, 1))
+ set(61, signedToBits(lon600000, 28))
+ set(89, signedToBits(lat600000, 27))
+ set(116, intToBits(cog10, 12))
+ set(128, intToBits(heading, 9))
+ return encodeBits(bits)
+}
+
+private fun buildType5Payload(mmsi: Int, callsign: String, name: String, vesselType: Int): String {
+ val bits = IntArray(426)
+ fun set(start: Int, arr: IntArray) { arr.copyInto(bits, start) }
+ set(0, intToBits(5, 6))
+ set(6, intToBits(0, 2))
+ set(8, intToBits(mmsi, 30))
+ set(38, intToBits(0, 2))
+ set(40, intToBits(0, 30))
+ val csFixed = callsign.padEnd(7, '@').take(7)
+ for (i in 0 until 7) {
+ val c = csFixed[i].code
+ set(70 + i * 6, intToBits(if (c >= 64) c - 64 else c, 6))
+ }
+ val nameFixed = name.padEnd(20, '@').take(20)
+ for (i in 0 until 20) {
+ val c = nameFixed[i].code
+ set(112 + i * 6, intToBits(if (c >= 64) c - 64 else c, 6))
+ }
+ set(232, intToBits(vesselType, 8))
+ return encodeBits(bits)
+}
+
+private fun nmea0183Checksum(body: String): String {
+ var xor = 0
+ for (c in body) xor = xor xor c.code
+ return xor.toString(16).uppercase().padStart(2, '0')
+}
+
+private fun makeVdm(payload: String, padding: Int = 0): String {
+ val body = "AIVDM,1,1,,A,$payload,$padding"
+ return "!$body*${nmea0183Checksum(body)}"
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+class AisRepositoryTest {
+
+ private fun type1Sentence(mmsi: Int) = makeVdm(buildType1Payload(mmsi))
+ private fun type5Sentence(mmsi: Int, callsign: String, name: String, vesselType: Int = 0) =
+ makeVdm(buildType5Payload(mmsi, callsign, name, vesselType))
+
+ @Test
+ fun `processSentence with type-1 adds vessel to targets`() {
+ val repo = AisRepository()
+ repo.processSentence(type1Sentence(123456789))
+ assertEquals(1, repo.getTargets().size)
+ }
+
+ @Test
+ fun `processSentence same MMSI twice updates position and keeps 1 target`() {
+ val repo = AisRepository()
+ val sentence = type1Sentence(123456789)
+ repo.processSentence(sentence)
+ repo.processSentence(sentence)
+ assertEquals(1, repo.getTargets().size)
+ }
+
+ @Test
+ fun `processSentence type-5 for existing MMSI merges name callsign and vesselType`() {
+ val repo = AisRepository()
+ repo.processSentence(type1Sentence(999001))
+ repo.processSentence(type5Sentence(999001, "W1ABC", "MY VESSEL", 36))
+ val targets = repo.getTargets()
+ assertEquals(1, targets.size)
+ assertEquals("MY VESSEL", targets[0].name)
+ assertEquals("W1ABC", targets[0].callsign)
+ assertEquals(36, targets[0].vesselType)
+ }
+
+ @Test
+ fun `processSentence type-5 before position applies pending static data when position arrives`() {
+ val repo = AisRepository()
+ repo.processSentence(type5Sentence(999002, "CALL", "PENDING VESSEL", 60))
+ assertEquals(0, repo.getTargets().size)
+ repo.processSentence(type1Sentence(999002))
+ val targets = repo.getTargets()
+ assertEquals(1, targets.size)
+ assertEquals("PENDING VESSEL", targets[0].name)
+ assertEquals("CALL", targets[0].callsign)
+ assertEquals(60, targets[0].vesselType)
+ }
+
+ @Test
+ fun `evictStale removes old targets and keeps recent ones`() {
+ val repo = AisRepository(staleTimeoutMs = 60_000L)
+ repo.processSentence(type1Sentence(111))
+ repo.processSentence(type1Sentence(222))
+ // Evict far into the future: all vessels are stale
+ repo.evictStale(System.currentTimeMillis() + 120_000L)
+ assertEquals(0, repo.getTargets().size)
+ // Add a fresh vessel and evict at now: it should survive
+ repo.processSentence(type1Sentence(333))
+ repo.evictStale(System.currentTimeMillis())
+ assertEquals(1, repo.getTargets().size)
+ }
+
+ @Test
+ fun `processSentence with non-AIS sentence does not change targets`() {
+ val repo = AisRepository()
+ repo.processSentence("\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A")
+ assertEquals(0, repo.getTargets().size)
+ }
+
+ @Test
+ fun `two different MMSIs both appear in getTargets`() {
+ val repo = AisRepository()
+ repo.processSentence(type1Sentence(111111111))
+ repo.processSentence(type1Sentence(222222222))
+ assertEquals(2, repo.getTargets().size)
+ val mmsis = repo.getTargets().map { it.mmsi }.toSet()
+ assertTrue(111111111 in mmsis)
+ assertTrue(222222222 in mmsis)
+ }
+}