diff options
Diffstat (limited to 'android-app/app/src/main')
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> |
