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