summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-06-03 01:54:38 +0000
committerClaude <noreply@anthropic.com>2026-06-03 01:54:38 +0000
commitbfeab101083970a3fd5a67aa62d09a7506027da4 (patch)
treece5e21a6749dc2516b72a6970bbdc2e68329781c /android-app/app
parent17cfd884976b4accbb2ad6af6acf3578d0c6ec53 (diff)
PMIM-1 hardware support: UDP, IIXDR, inclinometer
Transport: - NmeaStreamManager supports TCP and UDP modes - NmeaConnectionPrefs replaces hardcoded 192.168.1.1:10110; defaults to UDP/10110 matching PMIM-1 spec (ESP32 AP at 192.168.4.1) - LocationService reads prefs via startNmeaFromPrefs() Parsing: - NmeaParser.parseXdr() handles $IIXDR attitude (HEEL/PTCH in degrees) and baro (pressure in bar → hPa, air temp °C) - New AttitudeData and NmeaBaroData sensor types UI: - Heel + pitch row in instrument sheet (hidden until data arrives) - InclinometerView: canvas circle with rotating waterline (heel) and displaced dot (pitch), colorPrimary-tinted, respects night mode - Inclinometer overlay top-left of map, appears on first attitude packet - NMEA baro overrides device sensor reading when available https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt44
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt31
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt48
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaStreamManager.kt166
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt107
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml11
-rw-r--r--android-app/app/src/main/res/layout/layout_instruments_sheet.xml63
10 files changed, 420 insertions, 88 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 9440d49..c47c7a5 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
@@ -23,9 +23,12 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.asStateFlow
import org.terst.nav.nmea.NmeaParser
import org.terst.nav.nmea.NmeaStreamManager
+import org.terst.nav.nmea.NmeaTransport
+import org.terst.nav.sensors.AttitudeData
import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.NmeaBaroData
import org.terst.nav.sensors.WindData
import org.terst.nav.gps.GpsPosition
import org.terst.nav.data.model.SensorData
@@ -136,6 +139,20 @@ class LocationService : Service() {
}
}
+ // Collect NMEA Attitude Data (heel + pitch from PMIM-1 IIXDR)
+ serviceScope.launch {
+ nmeaStreamManager.nmeaAttitudeData.collectLatest { attitude ->
+ _nmeaAttitudeFlow.emit(attitude)
+ }
+ }
+
+ // Collect NMEA Baro Data (pressure + air temp from PMIM-1 IIXDR)
+ serviceScope.launch {
+ nmeaStreamManager.nmeaBaroData.collectLatest { baro ->
+ _nmeaBaroFlow.emit(baro)
+ }
+ }
+
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
@@ -240,9 +257,9 @@ class LocationService : Service() {
startLocationUpdatesInternal(PowerMode.ECONOMY)
}
barometerSensorManager.start()
- val flags = (application as NavApplication).featureFlags
- if (flags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) {
- nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ val featureFlags = (application as NavApplication).featureFlags
+ if (featureFlags.isEnabled(org.terst.nav.settings.HardwareSource.NMEA_INSTRUMENTS)) {
+ startNmeaFromPrefs()
}
}
ACTION_STOP_FOREGROUND_SERVICE -> {
@@ -274,7 +291,7 @@ class LocationService : Service() {
}
ACTION_START_NMEA -> {
Log.d("LocationService", "Starting NMEA stream")
- nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
+ startNmeaFromPrefs()
}
ACTION_STOP_NMEA -> {
Log.d("LocationService", "Stopping NMEA stream")
@@ -317,6 +334,16 @@ class LocationService : Service() {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
+ private fun startNmeaFromPrefs() {
+ val prefs = NmeaConnectionPrefs(this)
+ val transport = when (prefs.transport) {
+ NmeaConnectionPrefs.Transport.UDP -> NmeaTransport.UDP
+ NmeaConnectionPrefs.Transport.TCP -> NmeaTransport.TCP
+ }
+ nmeaStreamManager.start(prefs.tcpHost, prefs.port, transport)
+ Log.i("LocationService", "NMEA started: ${prefs.transport} port=${prefs.port}")
+ }
+
fun setPowerMode(powerMode: PowerMode) {
serviceScope.launch {
if (_currentPowerMode.value != powerMode) {
@@ -395,9 +422,6 @@ class LocationService : Service() {
const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
- private const val NMEA_GATEWAY_IP = "192.168.1.1"
- private const val NMEA_GATEWAY_PORT = 10110
-
private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1)
val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow
@@ -431,6 +455,12 @@ class LocationService : Service() {
private val _nmeaBoatSpeedDataFlow = MutableSharedFlow<BoatSpeedData>(replay = 1)
val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> get() = _nmeaBoatSpeedDataFlow
+ private val _nmeaAttitudeFlow = MutableSharedFlow<AttitudeData>(replay = 1)
+ val nmeaAttitudeFlow: SharedFlow<AttitudeData> get() = _nmeaAttitudeFlow
+
+ private val _nmeaBaroFlow = MutableSharedFlow<NmeaBaroData>(replay = 1)
+ val nmeaBaroFlow: SharedFlow<NmeaBaroData> get() = _nmeaBaroFlow
+
private val _currentSpeedKt = MutableStateFlow<Double?>(null)
private val _currentDirectionDeg = MutableStateFlow<Double?>(null)
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 69ca666..b66c20b 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
@@ -86,6 +86,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private lateinit var bottomSheet: CardView
private lateinit var bottomNav: BottomNavigationView
private lateinit var mapCrosshair: View
+ private lateinit var inclinometerView: org.terst.nav.ui.InclinometerView
// HUD value TextViews
private lateinit var hudSog: TextView
@@ -222,6 +223,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
bottomSheet = findViewById(R.id.instrument_bottom_sheet)
bottomNav = findViewById(R.id.bottom_navigation)
mapCrosshair = findViewById(R.id.map_crosshair)
+ inclinometerView = findViewById(R.id.inclinometer_view)
// HUD value views
hudSog = findViewById(R.id.hud_sog)
@@ -829,6 +831,28 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
}
lifecycleScope.launch {
+ LocationService.nmeaAttitudeFlow.collect { attitude ->
+ val heelText = "%+.1f".format(Locale.getDefault(), attitude.heelDeg)
+ val pitchText = "%+.1f".format(Locale.getDefault(), attitude.pitchDeg)
+ findViewById<android.widget.TextView>(R.id.value_heel)?.text = heelText
+ findViewById<android.widget.TextView>(R.id.value_pitch)?.text = pitchText
+ findViewById<android.view.View>(R.id.attitude_row)?.visibility = android.view.View.VISIBLE
+ inclinometerView.heelDeg = attitude.heelDeg
+ inclinometerView.pitchDeg = attitude.pitchDeg
+ inclinometerView.visibility = android.view.View.VISIBLE
+ }
+ }
+ lifecycleScope.launch {
+ LocationService.nmeaBaroFlow.collect { baro ->
+ lastBaroHpa = baro.pressureHpa
+ instrumentHandler?.updateConditions(baroHpa = baro.pressureHpa)
+ baro.airTempC?.let { tempC ->
+ lastConditions = lastConditions?.copy(tempC = tempC.toDouble())
+ instrumentHandler?.updateConditions(tempC = tempC.toDouble())
+ }
+ }
+ }
+ lifecycleScope.launch {
LocationService.anchorWatchState.collect { state ->
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt b/android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt
new file mode 100644
index 0000000..19ad7a2
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/NmeaConnectionPrefs.kt
@@ -0,0 +1,31 @@
+package org.terst.nav
+
+import android.content.Context
+
+class NmeaConnectionPrefs(context: Context) {
+
+ private val prefs = context.getSharedPreferences("nmea_connection", Context.MODE_PRIVATE)
+
+ enum class Transport { TCP, UDP }
+
+ var transport: Transport
+ get() = runCatching { Transport.valueOf(prefs.getString(KEY_TRANSPORT, null) ?: "") }
+ .getOrDefault(Transport.UDP)
+ set(v) { prefs.edit().putString(KEY_TRANSPORT, v.name).apply() }
+
+ var tcpHost: String
+ get() = prefs.getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
+ set(v) { prefs.edit().putString(KEY_HOST, v).apply() }
+
+ var port: Int
+ get() = prefs.getInt(KEY_PORT, DEFAULT_PORT)
+ set(v) { prefs.edit().putInt(KEY_PORT, v).apply() }
+
+ companion object {
+ private const val KEY_TRANSPORT = "transport"
+ private const val KEY_HOST = "host"
+ private const val KEY_PORT = "port"
+ const val DEFAULT_HOST = "192.168.4.1"
+ const val DEFAULT_PORT = 10110
+ }
+}
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 6a470b8..d634115 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,9 +1,11 @@
package org.terst.nav.nmea
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.AttitudeData
import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.NmeaBaroData
import org.terst.nav.sensors.WindData
import java.util.Calendar
import java.util.TimeZone
@@ -213,6 +215,7 @@ class NmeaParser {
"DBT" -> parseDbt(sentence)
"HDG", "HDM" -> parseHdg(sentence)
"VHW" -> parseVhw(sentence)
+ "XDR" -> parseXdr(sentence)
else -> null
}
}
@@ -251,6 +254,51 @@ class NmeaParser {
}
/**
+ * Parses $IIXDR (transducer data). Handles the two sentence types emitted by PMIM-1:
+ *
+ * Attitude: $IIXDR,A,{heel},D,HEEL,A,{pitch},D,PTCH*xx
+ * Baro: $IIXDR,P,{bar},B,Barometer,C,{tempC},C,TempAir*xx
+ *
+ * Returns AttitudeData, NmeaBaroData, or null.
+ */
+ fun parseXdr(sentence: String): Any? {
+ if (sentence.isBlank()) return null
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 5 || !fields[0].endsWith("XDR")) return null
+
+ val now = System.currentTimeMillis()
+ var heelDeg: Float? = null
+ var pitchDeg: Float? = null
+ var pressureHpa: Float? = null
+ var airTempC: Float? = null
+
+ var i = 1
+ while (i + 3 < fields.size) {
+ val type = fields[i]
+ val value = fields[i + 1].toFloatOrNull()
+ val unit = fields[i + 2]
+ val id = fields[i + 3]
+ i += 4
+ if (value == null) continue
+ when {
+ type == "A" && unit == "D" && id.equals("HEEL", ignoreCase = true) -> heelDeg = value
+ type == "A" && unit == "D" && id.equals("PTCH", ignoreCase = true) -> pitchDeg = value
+ type == "P" && unit == "B" && id.equals("Barometer", ignoreCase = true) -> pressureHpa = value * 1000f
+ type == "C" && unit == "C" && id.equals("TempAir", ignoreCase = true) -> airTempC = value
+ }
+ }
+
+ return when {
+ heelDeg != null || pitchDeg != null ->
+ AttitudeData(heelDeg ?: 0f, pitchDeg ?: 0f, now)
+ pressureHpa != null ->
+ NmeaBaroData(pressureHpa, airTempC, now)
+ 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
index 981b32e..17c6bfb 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,16 +12,22 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.sensors.AttitudeData
import org.terst.nav.sensors.BoatSpeedData
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
+import org.terst.nav.sensors.NmeaBaroData
import org.terst.nav.sensors.WindData
import java.io.BufferedReader
import java.io.InputStreamReader
+import java.net.DatagramPacket
+import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.net.Socket
import java.util.concurrent.atomic.AtomicBoolean
+enum class NmeaTransport { TCP, UDP }
+
class NmeaStreamManager(
private val parser: NmeaParser,
private val connectionScope: CoroutineScope
@@ -29,101 +35,101 @@ class NmeaStreamManager(
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
+ private fun <T> flow() = MutableSharedFlow<T>(
+ 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 _nmeaGpsPosition = flow<GpsPosition>()
+ private val _nmeaWindData = flow<WindData>()
+ private val _nmeaDepthData = flow<DepthData>()
+ private val _nmeaHeadingData = flow<HeadingData>()
+ private val _nmeaBoatSpeedData = flow<BoatSpeedData>()
+ private val _nmeaAttitudeData = flow<AttitudeData>()
+ private val _nmeaBaroData = flow<NmeaBaroData>()
- private val _nmeaHeadingData = MutableSharedFlow<HeadingData>(
- replay = 0,
- extraBufferCapacity = 1,
- onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- 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()
+ val nmeaGpsPosition: SharedFlow<GpsPosition> = _nmeaGpsPosition.asSharedFlow()
+ val nmeaWindData: SharedFlow<WindData> = _nmeaWindData.asSharedFlow()
+ val nmeaDepthData: SharedFlow<DepthData> = _nmeaDepthData.asSharedFlow()
+ val nmeaHeadingData: SharedFlow<HeadingData> = _nmeaHeadingData.asSharedFlow()
+ val nmeaBoatSpeedData: SharedFlow<BoatSpeedData> = _nmeaBoatSpeedData.asSharedFlow()
+ val nmeaAttitudeData: SharedFlow<AttitudeData> = _nmeaAttitudeData.asSharedFlow()
+ val nmeaBaroData: SharedFlow<NmeaBaroData> = _nmeaBaroData.asSharedFlow()
- fun start(address: String, port: Int) {
- if (connectionJob?.isActive == true) {
- Log.d(TAG, "NMEA stream already running.")
- return
+ fun start(address: String, port: Int, transport: NmeaTransport = NmeaTransport.TCP) {
+ if (connectionJob?.isActive == true) return
+ connectionJob = when (transport) {
+ NmeaTransport.TCP -> startTcp(address, port)
+ NmeaTransport.UDP -> startUdp(port)
}
+ }
- 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")
+ fun stop() {
+ connectionJob?.cancel()
+ connectionJob = null
+ isConnected.set(false)
+ }
- 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)
- is BoatSpeedData -> _nmeaBoatSpeedData.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
- }
- }
- }
+ private fun startTcp(address: String, port: Int) = connectionScope.launch(Dispatchers.IO) {
+ while (isActive) {
+ Log.d(TAG, "TCP connecting to $address:$port")
+ try {
+ Socket().use { socket ->
+ socket.connect(InetSocketAddress(address, port), CONNECTION_TIMEOUT_MS)
+ isConnected.set(true)
+ Log.i(TAG, "TCP connected to $address:$port")
+ BufferedReader(InputStreamReader(socket.getInputStream())).use { reader ->
+ var line: String?
+ while (isActive) {
+ line = reader.readLine() ?: break
+ processLine(line)
}
- } catch (e: Exception) {
- Log.e(TAG, "NMEA connection error: ${e.message}", e)
- isConnected.set(false)
}
}
- if (!isConnected.get()) {
- delay(RETRY_DELAY_MS)
+ } catch (e: Exception) {
+ Log.e(TAG, "TCP error: ${e.message}")
+ }
+ isConnected.set(false)
+ delay(RETRY_DELAY_MS)
+ }
+ }
+
+ private fun startUdp(port: Int) = connectionScope.launch(Dispatchers.IO) {
+ Log.i(TAG, "UDP listening on port $port")
+ try {
+ DatagramSocket(port).use { socket ->
+ isConnected.set(true)
+ val buffer = ByteArray(4096)
+ val packet = DatagramPacket(buffer, buffer.size)
+ while (isActive) {
+ socket.receive(packet)
+ val text = String(packet.data, 0, packet.length, Charsets.US_ASCII)
+ text.split('\n').forEach { raw ->
+ val line = raw.trimEnd('\r', ' ')
+ if (line.isNotBlank()) processLine(line)
+ }
}
}
- Log.d(TAG, "NMEA connection job finished.")
+ } catch (e: Exception) {
+ Log.e(TAG, "UDP error: ${e.message}")
+ isConnected.set(false)
}
}
- fun stop() {
- connectionJob?.cancel()
- connectionJob = null
- isConnected.set(false)
- Log.i(TAG, "NMEA stream stopped.")
+ private fun processLine(line: String) {
+ parser.parse(line)?.let { parsed ->
+ connectionScope.launch {
+ when (parsed) {
+ is GpsPosition -> _nmeaGpsPosition.emit(parsed)
+ is WindData -> _nmeaWindData.emit(parsed)
+ is DepthData -> _nmeaDepthData.emit(parsed)
+ is HeadingData -> _nmeaHeadingData.emit(parsed)
+ is BoatSpeedData -> _nmeaBoatSpeedData.emit(parsed)
+ is AttitudeData -> _nmeaAttitudeData.emit(parsed)
+ is NmeaBaroData -> _nmeaBaroData.emit(parsed)
+ else -> Log.w(TAG, "Unhandled: ${parsed::class.simpleName}")
+ }
+ }
+ }
}
companion object {
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt
new file mode 100644
index 0000000..1969c0e
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/AttitudeData.kt
@@ -0,0 +1,7 @@
+package org.terst.nav.sensors
+
+data class AttitudeData(
+ val heelDeg: Float,
+ val pitchDeg: Float,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt b/android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt
new file mode 100644
index 0000000..2f2bae5
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/sensors/NmeaBaroData.kt
@@ -0,0 +1,7 @@
+package org.terst.nav.sensors
+
+data class NmeaBaroData(
+ val pressureHpa: Float,
+ val airTempC: Float?,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt
new file mode 100644
index 0000000..c472375
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InclinometerView.kt
@@ -0,0 +1,107 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import androidx.core.content.ContextCompat
+import org.terst.nav.R
+import kotlin.math.min
+
+/**
+ * Compact inclinometer showing heel (rotation) and pitch (dot displacement).
+ * Heel: the waterline rotates — starboard-down is positive.
+ * Pitch: dot displaces up (bow up = positive).
+ */
+class InclinometerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ var heelDeg: Float = 0f
+ set(value) { field = value; invalidate() }
+
+ var pitchDeg: Float = 0f
+ set(value) { field = value; invalidate() }
+
+ private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ color = Color.parseColor("#CC000000")
+ }
+
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ color = Color.parseColor("#44FFFFFF")
+ }
+
+ private val horizonPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ }
+
+ private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ }
+
+ private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1f
+ color = Color.parseColor("#33FFFFFF")
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ val primary = try {
+ val tv = android.util.TypedValue()
+ context.theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, tv, true)
+ tv.data
+ } catch (e: Exception) {
+ Color.parseColor("#2B8FC4")
+ }
+ horizonPaint.color = primary
+ horizonPaint.strokeWidth = density * 2.5f
+ dotPaint.color = primary
+ }
+
+ private val density get() = resources.displayMetrics.density
+
+ override fun onDraw(canvas: Canvas) {
+ val cx = width / 2f
+ val cy = height / 2f
+ val r = min(width, height) / 2f - density * 2f
+
+ // Background + border
+ canvas.drawCircle(cx, cy, r, bgPaint)
+ canvas.drawCircle(cx, cy, r, borderPaint)
+
+ // Clip to circle
+ canvas.save()
+ canvas.clipRect(cx - r, cy - r, cx + r, cy + r)
+
+ // Tick marks at 10° intervals (subtle)
+ for (angle in listOf(-30f, -20f, -10f, 10f, 20f, 30f)) {
+ canvas.save()
+ canvas.rotate(angle, cx, cy)
+ canvas.drawLine(cx, cy - r, cx, cy - r + density * 5f, tickPaint)
+ canvas.restore()
+ }
+
+ // Heel: rotate the horizon line
+ canvas.save()
+ canvas.rotate(heelDeg, cx, cy)
+ val lineLen = r * 0.78f
+ canvas.drawLine(cx - lineLen, cy, cx + lineLen, cy, horizonPaint)
+ canvas.restore()
+
+ // Pitch: displace dot (up = bow up = negative Y, clamped to ±30°)
+ val pitchClamped = pitchDeg.coerceIn(-30f, 30f)
+ val pitchOffsetY = -(pitchClamped / 30f) * r * 0.35f
+ canvas.drawCircle(cx, cy + pitchOffsetY, density * 3.5f, dotPaint)
+
+ canvas.restore()
+ }
+}
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index ca13105..d3b7942 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -53,6 +53,17 @@
app:layout_constraintTop_toBottomOf="@id/nav_hud"
app:layout_constraintEnd_toEndOf="parent" />
+ <!-- Inclinometer overlay — top-left, hidden until attitude data arrives -->
+ <org.terst.nav.ui.InclinometerView
+ android:id="@+id/inclinometer_view"
+ android:layout_width="72dp"
+ android:layout_height="72dp"
+ android:layout_marginStart="12dp"
+ android:layout_marginTop="8dp"
+ android:visibility="gone"
+ app:layout_constraintTop_toBottomOf="@id/nav_hud"
+ app:layout_constraintStart_toStartOf="parent" />
+
<!-- Night mode toggle FAB -->
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_night_mode"
diff --git a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
index 5e58009..96298f9 100644
--- a/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
+++ b/android-app/app/src/main/res/layout/layout_instruments_sheet.xml
@@ -158,6 +158,67 @@
</LinearLayout>
+ <!-- Heel + Pitch row — hidden until PMIM attitude data arrives -->
+ <LinearLayout
+ android:id="@+id/attitude_row"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:visibility="gone"
+ app:layout_constraintTop_toBottomOf="@id/conditions_header"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="Heel" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_heel"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="-5.2" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="8dp">
+ <TextView style="@style/InstrumentLabel" android:text="Pitch" />
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ <TextView
+ android:id="@+id/value_pitch"
+ style="@style/InstrumentPrimaryValue"
+ tools:text="+2.1" />
+ <TextView
+ style="@style/InstrumentUnit"
+ android:text="°" />
+ </LinearLayout>
+ </LinearLayout>
+
+ </LinearLayout>
+
<!-- Animated wave divider — colors adapt via wave_sky/sea color resources -->
<org.terst.nav.ui.WaveView
android:id="@+id/wave_divider"
@@ -165,7 +226,7 @@
android:layout_height="72dp"
android:layout_marginStart="-16dp"
android:layout_marginEnd="-16dp"
- app:layout_constraintTop_toBottomOf="@id/conditions_header"
+ app:layout_constraintTop_toBottomOf="@id/attitude_row"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />