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/LocationService.kt118
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt188
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt125
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt8
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt8
7 files changed, 437 insertions, 23 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 24eb498..d9233a4 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -1,5 +1,6 @@
package org.terst.nav
+import android.util.Log
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
@@ -18,7 +19,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
-import android.util.Log
+import org.terst.nav.nmea.NmeaParser
+import org.terst.nav.nmea.NmeaStreamManager
+import org.terst.nav.sensors.DepthData
+import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.WindData
+import org.terst.nav.gps.GpsPosition
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -48,6 +55,8 @@ class LocationService : Service() {
private lateinit var locationCallback: LocationCallback
private lateinit var anchorAlarmManager: AnchorAlarmManager
private lateinit var barometerSensorManager: BarometerSensorManager
+ private lateinit var nmeaParser: NmeaParser
+ private lateinit var nmeaStreamManager: NmeaStreamManager
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
@@ -61,6 +70,8 @@ class LocationService : Service() {
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
barometerSensorManager = BarometerSensorManager(this)
+ nmeaParser = NmeaParser()
+ nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope)
createNotificationChannel()
// Observe barometer status and update our public state
@@ -70,6 +81,36 @@ class LocationService : Service() {
}
}
+ // Collect NMEA GPS positions
+ serviceScope.launch {
+ nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
+ _nmeaGpsPositionFlow.emit(gpsPosition)
+ // TODO: Implement sensor fusion logic here to decide whether to use
+ // this NMEA GPS position or the Android system's FusedLocationProviderClient position.
+ }
+ }
+
+ // Collect NMEA Wind Data
+ serviceScope.launch {
+ nmeaStreamManager.nmeaWindData.collectLatest { windData ->
+ _nmeaWindDataFlow.emit(windData)
+ }
+ }
+
+ // Collect NMEA Depth Data
+ serviceScope.launch {
+ nmeaStreamManager.nmeaDepthData.collectLatest { depthData ->
+ _nmeaDepthDataFlow.emit(depthData)
+ }
+ }
+
+ // Collect NMEA Heading Data
+ serviceScope.launch {
+ nmeaStreamManager.nmeaHeadingData.collectLatest { headingData ->
+ _nmeaHeadingDataFlow.emit(headingData)
+ }
+ }
+
// Mock tidal current data generator
serviceScope.launch {
while (true) {
@@ -89,10 +130,11 @@ class LocationService : Service() {
courseOverGround = location.bearing
)
serviceScope.launch {
- _locationFlow.emit(gpsData) // Emit to shared flow
+ _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS)
}
+
// Check for anchor drag if anchor watch is active
_anchorWatchState.update { currentState ->
if (currentState.isActive && currentState.anchorLocation != null) {
@@ -129,23 +171,32 @@ class LocationService : Service() {
ACTION_START_FOREGROUND_SERVICE -> {
Log.d("LocationService", "Starting foreground service")
startForeground(NOTIFICATION_ID, createNotification())
- startLocationUpdatesInternal()
+ serviceScope.launch {
+ _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL
+ startLocationUpdatesInternal(PowerMode.FULL)
+ }
barometerSensorManager.start()
+ nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
}
ACTION_STOP_FOREGROUND_SERVICE -> {
Log.d("LocationService", "Stopping foreground service")
stopLocationUpdatesInternal()
barometerSensorManager.stop()
+ nmeaStreamManager.stop()
stopSelf()
}
ACTION_START_ANCHOR_WATCH -> {
Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
- serviceScope.launch { startAnchorWatch(radius) }
+ serviceScope.launch {
+ startAnchorWatch(radius)
+ setPowerMode(PowerMode.ANCHOR_WATCH)
+ }
}
ACTION_STOP_ANCHOR_WATCH -> {
Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
stopAnchorWatch()
+ setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch
}
ACTION_UPDATE_WATCH_RADIUS -> {
Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
@@ -170,16 +221,18 @@ class LocationService : Service() {
stopLocationUpdatesInternal()
anchorAlarmManager.stopAlarm()
barometerSensorManager.stop()
+ nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed
_anchorWatchState.value = AnchorWatchState(isActive = false)
isAlarmTriggered = false // Reset alarm trigger state
serviceScope.cancel() // Cancel the coroutine scope
}
+
@SuppressLint("MissingPermission")
- private fun startLocationUpdatesInternal() {
- Log.d("LocationService", "Requesting location updates")
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
- .setMinUpdateIntervalMillis(500)
+ private fun startLocationUpdatesInternal(powerMode: PowerMode) {
+ Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms")
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis)
+ .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
@@ -193,6 +246,22 @@ class LocationService : Service() {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
+ fun setPowerMode(powerMode: PowerMode) {
+ serviceScope.launch {
+ if (_currentPowerMode.value != powerMode) {
+ // Emit the new power mode first
+ _currentPowerMode.emit(powerMode)
+ Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.")
+ // Stop current updates if running
+ stopLocationUpdatesInternal()
+ // Start new updates with the new power mode's interval
+ startLocationUpdatesInternal(powerMode)
+ } else {
+ Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.")
+ }
+ }
+ }
+
private fun createNotificationChannel() {
val serviceChannel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
@@ -289,6 +358,10 @@ class LocationService : Service() {
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
+ // NMEA Gateway configuration (example values - these should ideally be configurable by the user)
+ private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address
+ private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port
+
// Publicly accessible flows
val locationFlow: SharedFlow<GpsData>
get() = _locationFlow
@@ -299,9 +372,38 @@ class LocationService : Service() {
val barometerStatus: StateFlow<BarometerStatus>
get() = _barometerStatus
+ // NMEA Data Flows
+ val nmeaGpsPositionFlow: SharedFlow<GpsPosition>
+ get() = _nmeaGpsPositionFlow
+ val nmeaWindDataFlow: SharedFlow<WindData>
+ get() = _nmeaWindDataFlow
+ val nmeaDepthDataFlow: SharedFlow<DepthData>
+ get() = _nmeaDepthDataFlow
+ val nmeaHeadingDataFlow: SharedFlow<HeadingData>
+ get() = _nmeaHeadingDataFlow
+
private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
private val _barometerStatus = MutableStateFlow(BarometerStatus())
+
+ // Private NMEA Data Flows
+ private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(
+ replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+
+ private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
+ val currentPowerMode: StateFlow<PowerMode>
+ get() = _currentPowerMode
}
}
+
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt b/android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt
new file mode 100644
index 0000000..22e1b77
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PowerMode.kt
@@ -0,0 +1,7 @@
+package org.terst.nav
+
+enum class PowerMode(val gpsUpdateIntervalMillis: Long) {
+ FULL(1000L), // 1 Hz
+ ECONOMY(5000L), // 0.2 Hz
+ ANCHOR_WATCH(10000L) // 0.1 Hz
+}
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 74f2c41..27d9c2c 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,9 @@
package org.terst.nav.nmea
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.DepthData
+import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.WindData
import java.util.Calendar
import java.util.TimeZone
@@ -16,37 +19,78 @@ class NmeaParser {
fun parseRmc(sentence: String): GpsPosition? {
if (sentence.isBlank()) return null
- // Strip optional checksum (*XX suffix)
val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
-
val fields = body.split(',')
if (fields.size < 10) return null
- // Sentence ID must end with "RMC"
if (!fields[0].endsWith("RMC")) return null
+ if (fields[2] != "A") return null // Status must be Active
- // Status must be Active; Void means no valid fix
- if (fields[2] != "A") return null
-
- val latStr = fields[3]
- val latDir = fields[4]
- val lonStr = fields[5]
- val lonDir = fields[6]
-
- if (latStr.isEmpty() || latDir.isEmpty() || lonStr.isEmpty() || lonDir.isEmpty()) return null
+ val latStr = fields.getOrNull(3) ?: return null
+ val latDir = fields.getOrNull(4) ?: return null
+ val lonStr = fields.getOrNull(5) ?: return null
+ val lonDir = fields.getOrNull(6) ?: return null
val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0
val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0
- val sog = fields[7].toDoubleOrNull() ?: 0.0
- val cog = fields[8].toDoubleOrNull() ?: 0.0
+ val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0
+ val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0
- val timestampMs = parseTimestamp(timeStr = fields[1], dateStr = fields[9])
+ // Date field is fields[9], time is fields[1]
+ val timestampMs = parseTimestamp(timeStr = fields.getOrNull(1) ?: "", dateStr = fields.getOrNull(9) ?: "")
+ if (timestampMs == 0L) return null // If timestamp parsing fails, consider the sentence invalid
return GpsPosition(latitude, longitude, sog, cog, timestampMs)
}
/**
+ * Parses an NMEA MWV sentence (Wind Speed and Angle) and returns a [WindData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIMWV,314.0,R,04.8,N,A*22
+ * Fields:
+ * 1: Wind Angle, 0.0 to 359.9 degrees
+ * 2: Reference (R = Relative, T = True)
+ * 3: Wind Speed
+ * 4: Wind Speed Units (N = Knots, M = Meters/sec, K = Km/hr)
+ * 5: Status (A = Data Valid, V = Data Invalid)
+ * (Checksum)
+ */
+ fun parseMwv(sentence: String): WindData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 6) return null
+
+ if (!fields[0].endsWith("MWV")) return null
+ if (fields.getOrNull(5) != "A") return null // Status must be A (Valid)
+
+ val windAngle = fields.getOrNull(1)?.toDoubleOrNull() ?: return null
+ val reference = fields.getOrNull(2) ?: return null
+ var windSpeed = fields.getOrNull(3)?.toDoubleOrNull() ?: return null
+ val speedUnits = fields.getOrNull(4) ?: return null
+
+ val isTrueWind = (reference == "T")
+
+ // Convert speed to knots if necessary
+ when (speedUnits) {
+ "M" -> windSpeed *= 1.94384 // m/s to knots
+ "K" -> windSpeed *= 0.539957 // km/h to knots
+ "N" -> { /* already in knots */ }
+ else -> return null // Unknown units
+ }
+
+ // MWV sentences don't typically include date. Use current time.
+ // In a real application, timestamp should be managed more carefully, possibly from a common system clock
+ // or a timestamp field if available in the NMEA stream.
+ val timestampMs = System.currentTimeMillis()
+
+ return WindData(windAngle, windSpeed, isTrueWind, timestampMs)
+ }
+
+ /**
* Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
* Works for both latitude (DDMM.MM) and longitude (DDDMM.MM) formats.
*/
@@ -58,6 +102,120 @@ class NmeaParser {
}
/**
+ * Parses an NMEA DBT sentence (Depth Below Transducer) and returns a [DepthData],
+ * or null if the sentence is malformed or cannot be parsed.
+ *
+ * Example: $IIDBT,005.6,f,01.7,M,009.2,F*21 (Depth: 1.7m)
+ * Fields:
+ * 1: Depth, feet
+ * 2: F = feet
+ * 3: Depth, meters
+ * 4: M = meters
+ * 5: Depth, fathoms
+ * 6: F = fathoms
+ * (Checksum)
+ */
+ fun parseDbt(sentence: String): DepthData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 5) return null // Minimum fields for depth in meters
+
+ if (!fields[0].endsWith("DBT")) return null
+
+ val depthMeters = fields.getOrNull(3)?.toDoubleOrNull() ?: return null
+ if (fields.getOrNull(4) != "M") return null // Ensure units are meters
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return DepthData(depthMeters, timestampMs)
+ }
+
+ /**
+ * Parses NMEA HDG (Heading, Deviation & Variation) or HDM (Heading - Magnetic)
+ * sentences and returns a [HeadingData], or null if malformed.
+ *
+ * HDG Example: $IIHDG,225.0,,,11.0,W*00
+ * Fields:
+ * 1: Magnetic Sensor Heading in degrees
+ * 2: Magnetic Deviation, degrees
+ * 3: Magnetic Variation, degrees
+ * 4: Magnetic Variation Direction (E/W)
+ *
+ * HDM Example: $IIHDM,225.0,M*30
+ * Fields:
+ * 1: Heading, Magnetic
+ * 2: M = Magnetic
+ */
+ fun parseHdg(sentence: String): HeadingData? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 2) return null
+
+ val talkerId = fields[0].substring(1,3)
+ val sentenceId = fields[0].substring(3)
+
+ val timestampMs = System.currentTimeMillis() // Use current time for now
+
+ return when (sentenceId) {
+ "HDG" -> {
+ if (fields.size < 5) return null
+ val magneticHeading = fields.getOrNull(1)?.toDoubleOrNull() ?: return null
+ // fields[2] (deviation) and fields[3] (variation) can be empty
+ val variation = fields.getOrNull(4)?.toDoubleOrNull()
+ val varDirection = fields.getOrNull(5)
+
+ val magneticVariation = if (variation != null && varDirection != null) {
+ if (varDirection == "W") -variation else variation
+ } else null
+
+ val trueHeading = if (magneticHeading != null && magneticVariation != null) {
+ (magneticHeading + magneticVariation + 360) % 360
+ } else magneticHeading // If variation is null, magneticHeading can be treated as true for display, or better to leave true as null
+
+ HeadingData(
+ headingDegreesTrue = trueHeading ?: magneticHeading, // Fallback to magnetic if true can't be calculated
+ headingDegreesMagnetic = magneticHeading,
+ magneticVariation = magneticVariation,
+ timestampMs = timestampMs
+ )
+ }
+ "HDM" -> {
+ if (fields.size < 2) return null
+ val magneticHeading = fields.getOrNull(1)?.toDoubleOrNull() ?: return null
+ HeadingData(
+ headingDegreesTrue = magneticHeading, // Assuming HDM is only magnetic, true cannot be derived without variation
+ headingDegreesMagnetic = magneticHeading,
+ magneticVariation = null,
+ timestampMs = timestampMs
+ )
+ }
+ else -> null
+ }
+ }
+
+ /**
+ * Parses a generic NMEA sentence and returns the corresponding data object,
+ * or null if the sentence type is not supported or malformed.
+ */
+ fun parse(sentence: String): Any? {
+ if (sentence.isBlank() || sentence.length < 6) return null // Minimum valid sentence length
+
+ val sentenceId = sentence.substring(3, 6) // e.g., "RMC", "MWV", "DBT", "HDG", "HDM"
+
+ return when (sentenceId) {
+ "RMC" -> parseRmc(sentence)
+ "MWV" -> parseMwv(sentence)
+ "DBT" -> parseDbt(sentence)
+ "HDG", "HDM" -> parseHdg(sentence)
+ else -> null
+ }
+ }
+
+ /**
* 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
new file mode 100644
index 0000000..4298f0d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt
@@ -0,0 +1,125 @@
+package org.terst.nav.nmea
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.DepthData
+import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.WindData
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.util.concurrent.atomic.AtomicBoolean
+
+class NmeaStreamManager(
+ private val parser: NmeaParser,
+ private val connectionScope: CoroutineScope
+) {
+ private var connectionJob: Job? = null
+ private val isConnected = AtomicBoolean(false)
+
+ // Flows to emit parsed data
+ private val _nmeaGpsPosition = MutableSharedFlow<GpsPosition>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaGpsPosition: SharedFlow<GpsPosition> = _nmeaGpsPosition.asSharedFlow()
+
+ private val _nmeaWindData = MutableSharedFlow<WindData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaWindData: SharedFlow<WindData> = _nmeaWindData.asSharedFlow()
+
+ private val _nmeaDepthData = MutableSharedFlow<DepthData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaDepthData: SharedFlow<DepthData> = _nmeaDepthData.asSharedFlow()
+
+ private val _nmeaHeadingData = MutableSharedFlow<HeadingData>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow()
+
+ fun start(address: String, port: Int) {
+ if (connectionJob?.isActive == true) {
+ Log.d(TAG, "NMEA stream already running.")
+ return
+ }
+
+ connectionJob = connectionScope.launch(Dispatchers.IO) {
+ while (isActive) {
+ if (!isConnected.get()) {
+ Log.d(TAG, "Attempting to connect to NMEA source: $address:$port")
+ try {
+ Socket().use { socket ->
+ socket.connect(InetSocketAddress(address, port), CONNECTION_TIMEOUT_MS)
+ isConnected.set(true)
+ Log.i(TAG, "Connected to NMEA source: $address:$port")
+
+ BufferedReader(InputStreamReader(socket.getInputStream())).use { reader ->
+ var line: String?
+ while (isActive && isConnected.get()) {
+ line = reader.readLine()
+ if (line != null) {
+ // Log.v(TAG, "NMEA: $line") // Too verbose for regular logging
+ parser.parse(line)?.let { parsedData ->
+ when (parsedData) {
+ is GpsPosition -> _nmeaGpsPosition.emit(parsedData)
+ is WindData -> _nmeaWindData.emit(parsedData)
+ is DepthData -> _nmeaDepthData.emit(parsedData)
+ is HeadingData -> _nmeaHeadingData.emit(parsedData)
+ else -> Log.w(TAG, "Unknown parsed NMEA data type: ${parsedData::class.simpleName}")
+ }
+ }
+ } else {
+ // End of stream, connection closed by server
+ Log.w(TAG, "NMEA stream ended, reconnecting...")
+ isConnected.set(false)
+ break
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "NMEA connection error: ${e.message}", e)
+ isConnected.set(false)
+ }
+ }
+ if (!isConnected.get()) {
+ delay(RETRY_DELAY_MS)
+ }
+ }
+ Log.d(TAG, "NMEA connection job finished.")
+ }
+ }
+
+ fun stop() {
+ connectionJob?.cancel()
+ connectionJob = null
+ isConnected.set(false)
+ Log.i(TAG, "NMEA stream stopped.")
+ }
+
+ companion object {
+ private const val TAG = "NmeaStreamManager"
+ private const val CONNECTION_TIMEOUT_MS = 5000
+ private const val RETRY_DELAY_MS = 5000L
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt
new file mode 100644
index 0000000..df31b40
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/DepthData.kt
@@ -0,0 +1,6 @@
+package org.terst.nav.sensors
+
+data class DepthData(
+ val depthMeters: Double,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt
new file mode 100644
index 0000000..8f7532a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/HeadingData.kt
@@ -0,0 +1,8 @@
+package org.terst.nav.sensors
+
+data class HeadingData(
+ val headingDegreesTrue: Double,
+ val headingDegreesMagnetic: Double?, // Nullable if not available
+ val magneticVariation: Double?, // Nullable if not available
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt
new file mode 100644
index 0000000..4f640ef
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt
@@ -0,0 +1,8 @@
+package org.terst.nav.sensors
+
+data class WindData(
+ val windAngle: Double, // degrees (0-359), relative or true
+ val windSpeed: Double, // knots
+ val isTrueWind: Boolean,
+ val timestampMs: Long
+)