summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt38
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt109
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt23
-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.kt61
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt14
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt45
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt129
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt59
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt70
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt167
-rw-r--r--android-app/app/src/main/res/drawable/ic_ship_arrow.xml9
16 files changed, 814 insertions, 7 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index aa35914..a3eebfc 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -19,6 +19,7 @@ import android.widget.TextView
import android.widget.Toast
import org.terst.nav.ui.voicelog.VoiceLogFragment
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
@@ -41,6 +42,7 @@ import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
import org.maplibre.geojson.LineString
+import org.terst.nav.ui.MainViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -133,6 +135,9 @@ class MainActivity : AppCompatActivity() {
private var currentWatchCircleRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
+ // ViewModel for AIS sentence processing
+ private val viewModel: MainViewModel by viewModels()
+
// Register the permissions callback, which handles the user's response to the
// system permissions dialog.
private val requestPermissionLauncher =
@@ -150,6 +155,7 @@ class MainActivity : AppCompatActivity() {
observeLocationUpdates() // Start observing location updates
observeAnchorWatchState() // Start observing anchor watch state
observeBarometerStatus() // Start observing barometer status
+ startAisHardwareFeed()
} else {
// Permissions denied, handle the case (e.g., show a message to the user)
Toast.makeText(this, "Location permissions denied", Toast.LENGTH_LONG).show()
@@ -184,6 +190,7 @@ class MainActivity : AppCompatActivity() {
observeLocationUpdates() // Start observing location updates
observeAnchorWatchState() // Start observing anchor watch state
observeBarometerStatus() // Start observing barometer status
+ startAisHardwareFeed()
}
mapView = findViewById<MapView>(R.id.mapView)
@@ -396,6 +403,29 @@ class MainActivity : AppCompatActivity() {
stopService(intent)
}
+ /**
+ * Start reading AIS NMEA sentences from a hardware receiver over TCP.
+ * Sentences are forwarded to the ViewModel for processing.
+ * Falls back gracefully when the hardware feed is unavailable.
+ */
+ private fun startAisHardwareFeed(host: String = "localhost", port: Int = 10110) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val socket = java.net.Socket(host, port)
+ val reader = socket.getInputStream().bufferedReader()
+ reader.lineSequence().forEach { line ->
+ if (line.startsWith("!")) {
+ withContext(Dispatchers.Main) {
+ viewModel.processAisSentence(line)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // Hardware feed unavailable — internet fallback will be used
+ }
+ }
+ }
+
private fun createMockPolarTable(): PolarTable {
// Example polar data for a hypothetical boat
// TWS 6 knots
@@ -451,10 +481,10 @@ class MainActivity : AppCompatActivity() {
// Create sources
anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID)
anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
-
+
anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID)
anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>()))
-
+
style.addSource(anchorPointSource!!)
style.addSource(anchorCircleSource!!)
@@ -554,8 +584,8 @@ class MainActivity : AppCompatActivity() {
val newState = !LocationService.tidalCurrentState.value.isVisible
// Since we cannot update the flow directly from MainActivity (it's owned by LocationService),
// we should ideally send an intent or use a shared state.
- // For this mock, we'll use a local update to the flow if it was a MutableStateFlow,
- // but it's a StateFlow in LocationService.
+ // For this mock, we'll use a local update to the flow if it was a MutableStateFlow,
+ // but it's a StateFlow in LocationService.
// Let's add a public update method or an action to LocationService.
val intent = Intent(this, LocationService::class.java).apply {
action = LocationService.ACTION_TOGGLE_TIDAL_VISIBILITY
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt
new file mode 100644
index 0000000..80a3250
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModel.kt
@@ -0,0 +1,109 @@
+package org.terst.nav
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+import org.terst.nav.nmea.NmeaStreamManager
+import org.terst.nav.sensors.BoatSpeedData
+import org.terst.nav.sensors.WindData
+import kotlin.math.cos
+import kotlin.math.abs
+
+class PerformanceViewModel(
+ private val nmeaStreamManager: NmeaStreamManager
+) : ViewModel() {
+
+ // Dummy PolarTable for now. In a real app, this would come from user settings/boat profile.
+ private val dummyPolarTable: PolarTable by lazy {
+ // Example polar data for a hypothetical boat
+ val curves = listOf(
+ PolarCurve(twS = 6.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 4.0),
+ PolarPoint(tWa = 45.0, bSp = 5.5),
+ PolarPoint(tWa = 60.0, bSp = 6.0),
+ PolarPoint(tWa = 90.0, bSp = 5.8),
+ PolarPoint(tWa = 120.0, bSp = 5.0),
+ PolarPoint(tWa = 150.0, bSp = 4.0),
+ PolarPoint(tWa = 180.0, bSp = 3.0)
+ )),
+ PolarCurve(twS = 10.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.0),
+ PolarPoint(tWa = 45.0, bSp = 7.0),
+ PolarPoint(tWa = 60.0, bSp = 7.5),
+ PolarPoint(tWa = 90.0, bSp = 7.0),
+ PolarPoint(tWa = 120.0, bSp = 6.0),
+ PolarPoint(tWa = 150.0, bSp = 5.0),
+ PolarPoint(tWa = 180.0, bSp = 4.0)
+ )),
+ PolarCurve(twS = 15.0, points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.8),
+ PolarPoint(tWa = 45.0, bSp = 8.0),
+ PolarPoint(tWa = 60.0, bSp = 8.5),
+ PolarPoint(tWa = 90.0, bSp = 7.8),
+ PolarPoint(tWa = 120.0, bSp = 6.8),
+ PolarPoint(tWa = 150.0, bSp = 5.8),
+ PolarPoint(tWa = 180.0, bSp = 4.8)
+ ))
+ )
+ PolarTable(curves)
+ }
+
+ private val _vmg = MutableStateFlow(0.0)
+ val vmg: StateFlow<Double> = _vmg.asStateFlow()
+
+ private val _polarPercentage = MutableStateFlow(0.0)
+ val polarPercentage: StateFlow<Double> = _polarPercentage.asStateFlow()
+
+ private var latestWindData: WindData? = null
+ private var latestBoatSpeedData: BoatSpeedData? = null
+
+ init {
+ viewModelScope.launch {
+ combine(
+ nmeaStreamManager.nmeaWindData,
+ nmeaStreamManager.nmeaBoatSpeedData
+ ) { windData, boatSpeedData ->
+ latestWindData = windData
+ latestBoatSpeedData = boatSpeedData
+ calculatePerformance()
+ }.collect { /* Do nothing, combine emits Unit after processing */ }
+ }
+ }
+
+ private fun calculatePerformance() {
+ val currentWind = latestWindData
+ val currentBoatSpeed = latestBoatSpeedData
+
+ if (currentWind != null && currentBoatSpeed != null) {
+ val tws = currentWind.windSpeed
+ val twa = currentWind.windAngle
+
+ // Ensure TWA is true wind angle for VMG calculation
+ val vmgValue = if (currentWind.isTrueWind) {
+ dummyPolarTable.curves.firstOrNull()?.calculateVmg(twa, currentBoatSpeed.bspKnots) ?: 0.0
+ } else {
+ // If wind is apparent, we cannot calculate true VMG directly from BSP * cos(TWA_apparent)
+ // Need true wind angle, which would typically be derived from apparent wind, heading, and boat speed
+ // For now, if only apparent wind is available, VMG calculation will be 0.0
+ // This scenario needs a more robust solution in a production app.
+ 0.0
+ }
+ _vmg.value = vmgValue
+
+ // Polar percentage requires True Wind Speed and True Wind Angle
+ val polarPercentageValue = if (currentWind.isTrueWind) {
+ dummyPolarTable.calculatePolarPercentage(tws, twa, currentBoatSpeed.bspKnots)
+ } else {
+ 0.0 // Cannot calculate polar percentage without true wind data
+ }
+ _polarPercentage.value = polarPercentageValue
+ } else {
+ _vmg.value = 0.0
+ _polarPercentage.value = 0.0
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt
new file mode 100644
index 0000000..ed6d1eb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PerformanceViewModelFactory.kt
@@ -0,0 +1,23 @@
+package org.terst.nav
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineScope
+import org.terst.nav.nmea.NmeaParser
+import org.terst.nav.nmea.NmeaStreamManager
+
+class PerformanceViewModelFactory : ViewModelProvider.Factory {
+ override fun <T : ViewModel> create(modelClass: Class<T>): T {
+ if (modelClass.isAssignableFrom(PerformanceViewModel::class.java)) {
+ // NmeaStreamManager will be tied to the ViewModel's lifecycle
+ val nmeaParser = NmeaParser()
+ // We'll pass the ViewModel's own viewModelScope to NmeaStreamManager
+ // The actual CoroutineScope passed here will be the one associated with the ViewModel
+ val nmeaStreamManager = NmeaStreamManager(nmeaParser, CoroutineScope(modelClass.kotlin.viewModelScope.coroutineContext))
+ @Suppress("UNCHECKED_CAST")
+ return PerformanceViewModel(nmeaStreamManager) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
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..75b1feb
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisRepository.kt
@@ -0,0 +1,61 @@
+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
+ }
+ }
+ }
+
+ /** Directly insert a vessel obtained from an internet source (e.g. AISHub). */
+ fun ingestVessel(vessel: AisVessel) {
+ targets[vessel.mmsi] = 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/main/kotlin/org/terst/nav/ais/AisVessel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt
new file mode 100644
index 0000000..34a951c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/AisVessel.kt
@@ -0,0 +1,14 @@
+package org.terst.nav.ais
+
+data class AisVessel(
+ val mmsi: Int,
+ val name: String,
+ val callsign: String,
+ val lat: Double,
+ val lon: Double,
+ val sog: Double,
+ val cog: Double,
+ val heading: Int,
+ val vesselType: Int,
+ val timestampMs: Long
+) \ No newline at end of file
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
new file mode 100644
index 0000000..298af59
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
@@ -0,0 +1,45 @@
+package org.terst.nav.ais
+
+import kotlin.math.cos
+import kotlin.math.sqrt
+
+object CpaCalculator {
+ fun compute(
+ ownLat: Double, ownLon: Double, ownSog: Double, ownCog: Double,
+ tgtLat: Double, tgtLon: Double, tgtSog: Double, tgtCog: Double
+ ): Pair<Double, Double> {
+ val refLat = Math.toRadians((ownLat + tgtLat) / 2.0)
+
+ // Flat-earth positions in nautical miles
+ val ownX = ownLon * 60.0 * cos(refLat)
+ val ownY = ownLat * 60.0
+ val tgtX = tgtLon * 60.0 * cos(refLat)
+ val tgtY = tgtLat * 60.0
+
+ // Velocities in nm/min
+ val ownCogRad = Math.toRadians(ownCog)
+ val tgtCogRad = Math.toRadians(tgtCog)
+ val ownVx = ownSog * Math.sin(ownCogRad) / 60.0
+ val ownVy = ownSog * Math.cos(ownCogRad) / 60.0
+ val tgtVx = tgtSog * Math.sin(tgtCogRad) / 60.0
+ val tgtVy = tgtSog * Math.cos(tgtCogRad) / 60.0
+
+ val dx = tgtX - ownX
+ val dy = tgtY - ownY
+ val dvx = tgtVx - ownVx
+ val dvy = tgtVy - ownVy
+
+ val dv2 = dvx * dvx + dvy * dvy
+ if (dv2 < 1e-9) {
+ val currentDist = sqrt(dx * dx + dy * dy)
+ return Pair(currentDist, 0.0)
+ }
+
+ val tcpa = -(dx * dvx + dy * dvy) / dv2
+ val cpaX = dx + dvx * tcpa
+ val cpaY = dy + dvy * tcpa
+ val cpa = sqrt(cpaX * cpaX + cpaY * cpaY)
+
+ return Pair(cpa, tcpa)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt
new file mode 100644
index 0000000..9709d21
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/AisVdmParser.kt
@@ -0,0 +1,129 @@
+package org.terst.nav.nmea
+
+import org.terst.nav.ais.AisVessel
+
+class AisVdmParser {
+ // Keyed by seqId -> list of (seq, payload)
+ private val fragments = mutableMapOf<String, MutableList<Pair<Int, String>>>()
+
+ fun parse(sentence: String): AisVessel? {
+ if (!sentence.startsWith("!AIVDM") && !sentence.startsWith("!AIVDO")) return null
+
+ val withoutBang = sentence.drop(1)
+ val starIdx = withoutBang.indexOf('*')
+ val body = if (starIdx >= 0) withoutBang.substring(0, starIdx) else withoutBang
+
+ val fields = body.split(",")
+ if (fields.size < 7) return null
+
+ val count = fields[1].toIntOrNull() ?: return null
+ val seq = fields[2].toIntOrNull() ?: return null
+ val seqId = fields[3]
+ val payload = fields[5]
+ val padding = fields[6].toIntOrNull() ?: 0
+
+ val combinedPayload: String
+ if (count <= 1) {
+ combinedPayload = payload
+ } else {
+ val list = fragments.getOrPut(seqId) { mutableListOf() }
+ list.add(seq to payload)
+ if (list.size < count) return null
+ fragments.remove(seqId)
+ combinedPayload = list.sortedBy { it.first }.joinToString("") { it.second }
+ }
+
+ val bits = decodeToBits(combinedPayload, padding)
+ if (bits.size < 6) return null
+
+ return when (extractUInt(bits, 0, 6)) {
+ 1, 2, 3 -> parseType123(bits)
+ 5 -> parseType5(bits)
+ else -> null
+ }
+ }
+
+ private fun decodeToBits(payload: String, padding: Int): IntArray {
+ val allBits = ArrayList<Int>(payload.length * 6)
+ for (c in payload) {
+ var v = c.code - 48
+ if (v > 39) v -= 8
+ for (b in 5 downTo 0) allBits.add((v ushr b) and 1)
+ }
+ return if (padding > 0 && padding < allBits.size)
+ allBits.dropLast(padding).toIntArray()
+ else
+ allBits.toIntArray()
+ }
+
+ private fun extractUInt(bits: IntArray, start: Int, len: Int): Int {
+ var v = 0
+ for (i in 0 until len) {
+ val bit = if (start + i < bits.size) bits[start + i] else 0
+ v = (v shl 1) or bit
+ }
+ return v
+ }
+
+ private fun extractInt(bits: IntArray, start: Int, len: Int): Int {
+ val unsigned = extractUInt(bits, start, len)
+ return if (len > 0 && (unsigned and (1 shl (len - 1))) != 0)
+ unsigned - (1 shl len)
+ else
+ unsigned
+ }
+
+ private fun parseType123(bits: IntArray): AisVessel? {
+ if (bits.size < 137) return null
+ val mmsi = extractUInt(bits, 8, 30)
+ val sogRaw = extractUInt(bits, 50, 10)
+ val sog = if (sogRaw == 1023) 0.0 else sogRaw / 10.0
+ val lonRaw = extractInt(bits, 61, 28)
+ val lon = if (lonRaw == 0x6791AC0) 0.0 else lonRaw / 600000.0
+ val latRaw = extractInt(bits, 89, 27)
+ val lat = if (latRaw == 0x3412140) 0.0 else latRaw / 600000.0
+ val cogRaw = extractUInt(bits, 116, 12)
+ val cog = if (cogRaw == 3600) 0.0 else cogRaw / 10.0
+ val heading = extractUInt(bits, 128, 9)
+ return AisVessel(
+ mmsi = mmsi,
+ name = "",
+ callsign = "",
+ lat = lat,
+ lon = lon,
+ sog = sog,
+ cog = cog,
+ heading = heading,
+ vesselType = 0,
+ timestampMs = System.currentTimeMillis()
+ )
+ }
+
+ private fun parseType5(bits: IntArray): AisVessel? {
+ if (bits.size < 240) return null
+ val mmsi = extractUInt(bits, 8, 30)
+ val callsign = decodeText(bits, 70, 7).trimEnd('@', ' ')
+ val name = decodeText(bits, 112, 20).trimEnd('@', ' ')
+ val vesselType = extractUInt(bits, 232, 8)
+ return AisVessel(
+ mmsi = mmsi,
+ name = name,
+ callsign = callsign,
+ lat = 0.0, lon = 0.0, sog = 0.0, cog = 0.0,
+ heading = 511,
+ vesselType = vesselType,
+ timestampMs = System.currentTimeMillis()
+ )
+ }
+
+ // AIS 6-bit text: bits<32 -> bits+64 (maps to @,A-Z,...), else bits (maps to space,digits,symbols)
+ private fun decodeText(bits: IntArray, start: Int, nChars: Int): String {
+ val sb = StringBuilder(nChars)
+ for (i in 0 until nChars) {
+ val v = extractUInt(bits, start + i * 6, 6)
+ val c = if (v < 32) v + 64 else v
+ sb.append(c.toChar())
+ }
+ return sb.toString()
+ }
+} \ No newline at end of file
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
index 27d9c2c..453c758 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -1,6 +1,7 @@
package org.terst.nav.nmea
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
@@ -211,11 +212,45 @@ class NmeaParser {
"MWV" -> parseMwv(sentence)
"DBT" -> parseDbt(sentence)
"HDG", "HDM" -> parseHdg(sentence)
+ "VHW" -> parseVhw(sentence)
else -> null
}
}
/**
+ * Parses an NMEA VHW sentence (Water speed and Heading) and returns a [BoatSpeedData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIVHW,,,2.1,N,,,*0A
+ * Fields:
+ * 1: Degrees True
+ * 2: T
+ * 3: Degrees Magnetic
+ * 4: M
+ * 5: Speed, knots, water
+ * 6: N = Knots
+ * 7: Speed, km/hr, water
+ * 8: K = km/hr
+ * (Checksum)
+ */
+ fun parseVhw(sentence: String): BoatSpeedData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 6) return null // Minimum fields for speed in knots
+
+ if (!fields[0].endsWith("VHW")) return null
+
+ val bspKnots = fields.getOrNull(4)?.toDoubleOrNull() ?: return null
+ if (fields.getOrNull(5) != "N") return null // Ensure units are knots
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return BoatSpeedData(bspKnots, timestampMs)
+ }
+
+ /**
* Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into a Unix epoch milliseconds value.
* Returns 0 on any parse failure.
*/
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
index 4298f0d..981b32e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
@@ -57,6 +58,13 @@ class NmeaStreamManager(
)
val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow()
+ private val _nmeaBoatSpeedData = MutableSharedFlow<BoatSpeedData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> = _nmeaBoatSpeedData.asSharedFlow()
+
fun start(address: String, port: Int) {
if (connectionJob?.isActive == true) {
Log.d(TAG, "NMEA stream already running.")
@@ -85,6 +93,7 @@ class NmeaStreamManager(
is WindData -> _nmeaWindData.emit(parsedData)
is DepthData -> _nmeaDepthData.emit(parsedData)
is HeadingData -> _nmeaHeadingData.emit(parsedData)
+ is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsedData)
else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}")
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt
new file mode 100644
index 0000000..9bdcbb3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/BoatSpeedData.kt
@@ -0,0 +1,6 @@
+package org.terst.nav.sensors
+
+data class BoatSpeedData(
+ val bspKnots: Double,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 53d02fd..8e84e1e 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -2,13 +2,22 @@ package org.terst.nav.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import org.terst.nav.ais.AisHubSource
+import org.terst.nav.ais.AisRepository
+import org.terst.nav.ais.AisVessel
+import org.terst.nav.data.api.AisHubApiService
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
sealed class UiState {
object Loading : UiState()
@@ -29,6 +38,23 @@ class MainViewModel(
private val _forecast = MutableStateFlow<List<ForecastItem>>(emptyList())
val forecast: StateFlow<List<ForecastItem>> = _forecast
+ private val _aisTargets = MutableStateFlow<List<AisVessel>>(emptyList())
+ val aisTargets: StateFlow<List<AisVessel>> = _aisTargets.asStateFlow()
+
+ private val aisRepository = AisRepository()
+
+ private val aisHubApi: AisHubApiService by lazy {
+ Retrofit.Builder()
+ .baseUrl("https://data.aishub.net")
+ .addConverterFactory(
+ MoshiConverterFactory.create(
+ Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
+ )
+ )
+ .build()
+ .create(AisHubApiService::class.java)
+ }
+
/**
* Fetch weather and marine data for [lat]/[lon] in parallel.
* Called once the device location is known.
@@ -60,4 +86,37 @@ class MainViewModel(
}
}
}
+
+ /**
+ * Process a single NMEA sentence from the hardware AIS receiver.
+ * Call this from MainActivity when bytes arrive from the TCP socket.
+ */
+ fun processAisSentence(sentence: String) {
+ aisRepository.processSentence(sentence)
+ aisRepository.evictStale()
+ _aisTargets.value = aisRepository.getTargets()
+ }
+
+ /**
+ * Refresh AIS targets from AISHub for the given bounding box.
+ * When username is empty, skips silently — hardware feed is primary.
+ */
+ fun refreshAisFromInternet(
+ latMin: Double, latMax: Double, lonMin: Double, lonMax: Double,
+ username: String = "", password: String = ""
+ ) {
+ if (username.isEmpty()) return
+ viewModelScope.launch {
+ try {
+ val vessels = aisHubApi.getVessels(username, password, latMin, latMax, lonMin, lonMax)
+ vessels.forEach { v ->
+ val av = AisHubSource.toAisVessel(v)
+ if (av != null) aisRepository.ingestVessel(av)
+ }
+ _aisTargets.value = aisRepository.getTargets()
+ } catch (e: Exception) {
+ // Log and ignore — hardware feed is primary
+ }
+ }
+ }
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
index ea7b596..ec3c927 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map/MapFragment.kt
@@ -13,6 +13,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import org.terst.nav.R
+import org.terst.nav.ais.AisVessel
import org.terst.nav.data.model.WindArrow
import org.terst.nav.databinding.FragmentMapBinding
import org.terst.nav.ui.MainViewModel
@@ -57,6 +58,7 @@ class MapFragment : Fragment() {
mapLibreMap = map
map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style ->
addWindArrowImage(style)
+ addShipArrowImage(style)
observeViewModel(style)
}
}
@@ -82,6 +84,11 @@ class MapFragment : Fragment() {
}
}
}
+ launch {
+ viewModel.aisTargets.collect { vessels ->
+ updateAisLayer(style, vessels)
+ }
+ }
}
}
}
@@ -100,6 +107,20 @@ class MapFragment : Fragment() {
style.addImage(WIND_ARROW_ICON, bitmap)
}
+ private fun addShipArrowImage(style: Style) {
+ val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_ship_arrow)
+ ?: return
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(24),
+ drawable.intrinsicHeight.coerceAtLeast(24),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ style.addImage(SHIP_ARROW_ICON, bitmap)
+ }
+
private fun updateWindLayer(style: Style, arrow: WindArrow) {
val feature = Feature.fromGeometry(
Point.fromLngLat(arrow.lon, arrow.lat)
@@ -134,6 +155,46 @@ class MapFragment : Fragment() {
}
}
+ private fun updateAisLayer(style: Style, vessels: List<AisVessel>) {
+ val features = vessels.map { vessel ->
+ val displayHeading = if (vessel.heading != 511) vessel.heading.toFloat() else vessel.cog.toFloat()
+ Feature.fromGeometry(Point.fromLngLat(vessel.lon, vessel.lat)).also { f ->
+ f.addNumberProperty("heading", displayHeading)
+ f.addStringProperty("name", vessel.name)
+ f.addNumberProperty("mmsi", vessel.mmsi)
+ f.addNumberProperty("sog", vessel.sog)
+ }
+ }
+ val collection = FeatureCollection.fromFeatures(features)
+
+ if (style.getSource(AIS_SOURCE_ID) == null) {
+ style.addSource(GeoJsonSource(AIS_SOURCE_ID, collection))
+ } else {
+ (style.getSource(AIS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection)
+ }
+
+ if (style.getLayer(AIS_LAYER_ID) == null) {
+ val layer = SymbolLayer(AIS_LAYER_ID, AIS_SOURCE_ID).withProperties(
+ PropertyFactory.iconImage(SHIP_ARROW_ICON),
+ PropertyFactory.iconRotate(Expression.get("heading")),
+ PropertyFactory.iconRotationAlignment("map"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(0.8f),
+ PropertyFactory.textField(
+ Expression.step(
+ Expression.zoom(),
+ Expression.literal(""),
+ Expression.stop(12.0, Expression.get("name"))
+ )
+ ),
+ PropertyFactory.textSize(11f),
+ PropertyFactory.textOffset(arrayOf(0f, 1.5f)),
+ PropertyFactory.textAllowOverlap(false)
+ )
+ style.addLayer(layer)
+ }
+ }
+
private fun centerMapOn(lat: Double, lon: Double) {
mapLibreMap?.cameraPosition = CameraPosition.Builder()
.target(LatLng(lat, lon))
@@ -159,9 +220,12 @@ class MapFragment : Fragment() {
}
companion object {
- private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
- private const val WIND_SOURCE_ID = "wind-source"
- private const val WIND_LAYER_ID = "wind-arrows"
+ private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
+ private const val WIND_SOURCE_ID = "wind-source"
+ private const val WIND_LAYER_ID = "wind-arrows"
private const val WIND_ARROW_ICON = "wind-arrow"
+ private const val AIS_SOURCE_ID = "ais-vessels-source"
+ private const val AIS_LAYER_ID = "ais-vessels"
+ private const val SHIP_ARROW_ICON = "ship-arrow"
}
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt
new file mode 100644
index 0000000..ea7b596
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/map_orig/MapFragment.kt
@@ -0,0 +1,167 @@
+package org.terst.nav.ui.map
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import org.terst.nav.R
+import org.terst.nav.data.model.WindArrow
+import org.terst.nav.databinding.FragmentMapBinding
+import org.terst.nav.ui.MainViewModel
+import org.terst.nav.ui.UiState
+import kotlinx.coroutines.launch
+import org.maplibre.android.MapLibre
+import org.maplibre.android.camera.CameraPosition
+import org.maplibre.android.geometry.LatLng
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.expressions.Expression
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.SymbolLayer
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.Point
+
+class MapFragment : Fragment() {
+
+ private var _binding: FragmentMapBinding? = null
+ private val binding get() = _binding!!
+
+ private val viewModel: MainViewModel by activityViewModels()
+
+ private var mapLibreMap: MapLibreMap? = null
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ MapLibre.getInstance(requireContext())
+ _binding = FragmentMapBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.mapView.onCreate(savedInstanceState)
+ binding.mapView.getMapAsync { map ->
+ mapLibreMap = map
+ map.setStyle(Style.Builder().fromUri(MAP_STYLE_URL)) { style ->
+ addWindArrowImage(style)
+ observeViewModel(style)
+ }
+ }
+ }
+
+ private fun observeViewModel(style: Style) {
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.uiState.collect { state ->
+ binding.statusText.visibility = when (state) {
+ UiState.Loading -> View.VISIBLE.also { binding.statusText.text = getString(R.string.loading_weather) }
+ UiState.Success -> View.GONE
+ is UiState.Error -> View.VISIBLE.also { binding.statusText.text = state.message }
+ }
+ }
+ }
+ launch {
+ viewModel.windArrow.collect { arrow ->
+ if (arrow != null) {
+ updateWindLayer(style, arrow)
+ centerMapOn(arrow.lat, arrow.lon)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun addWindArrowImage(style: Style) {
+ val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_wind_arrow)
+ ?: return
+ val bitmap = Bitmap.createBitmap(
+ drawable.intrinsicWidth.coerceAtLeast(24),
+ drawable.intrinsicHeight.coerceAtLeast(24),
+ Bitmap.Config.ARGB_8888
+ )
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ style.addImage(WIND_ARROW_ICON, bitmap)
+ }
+
+ private fun updateWindLayer(style: Style, arrow: WindArrow) {
+ val feature = Feature.fromGeometry(
+ Point.fromLngLat(arrow.lon, arrow.lat)
+ ).also { f ->
+ f.addNumberProperty("direction", arrow.directionDeg)
+ f.addNumberProperty("speed_kt", arrow.speedKt)
+ }
+ val collection = FeatureCollection.fromFeature(feature)
+
+ if (style.getSource(WIND_SOURCE_ID) == null) {
+ style.addSource(GeoJsonSource(WIND_SOURCE_ID, collection))
+ } else {
+ (style.getSource(WIND_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(collection)
+ }
+
+ if (style.getLayer(WIND_LAYER_ID) == null) {
+ val layer = SymbolLayer(WIND_LAYER_ID, WIND_SOURCE_ID).withProperties(
+ PropertyFactory.iconImage(WIND_ARROW_ICON),
+ PropertyFactory.iconRotate(Expression.get("direction")),
+ PropertyFactory.iconRotationAlignment("map"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconSize(
+ Expression.interpolate(
+ Expression.linear(),
+ Expression.get("speed_kt"),
+ Expression.stop(0, 0.6f),
+ Expression.stop(30, 1.4f)
+ )
+ )
+ )
+ style.addLayer(layer)
+ }
+ }
+
+ private fun centerMapOn(lat: Double, lon: Double) {
+ mapLibreMap?.cameraPosition = CameraPosition.Builder()
+ .target(LatLng(lat, lon))
+ .zoom(7.0)
+ .build()
+ }
+
+ // Lifecycle delegation to MapView
+ override fun onStart() { super.onStart(); binding.mapView.onStart() }
+ override fun onResume() { super.onResume(); binding.mapView.onResume() }
+ override fun onPause() { super.onPause(); binding.mapView.onPause() }
+ override fun onStop() { super.onStop(); binding.mapView.onStop() }
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ binding.mapView.onSaveInstanceState(outState)
+ }
+ override fun onLowMemory() { super.onLowMemory(); binding.mapView.onLowMemory() }
+
+ override fun onDestroyView() {
+ binding.mapView.onDestroy()
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ private const val MAP_STYLE_URL = "https://demotiles.maplibre.org/style.json"
+ private const val WIND_SOURCE_ID = "wind-source"
+ private const val WIND_LAYER_ID = "wind-arrows"
+ private const val WIND_ARROW_ICON = "wind-arrow"
+ }
+}
diff --git a/android-app/app/src/main/res/drawable/ic_ship_arrow.xml b/android-app/app/src/main/res/drawable/ic_ship_arrow.xml
new file mode 100644
index 0000000..68e8667
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_ship_arrow.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FF4081"
+ android:pathData="M12,2 L17,20 L12,17 L7,20 Z"/>
+</vector>