From 9417a7c6b08da362ad97e85973b7570e05d4f0b5 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 3 Apr 2026 07:38:22 +0000 Subject: feat(instruments): add forecast wind, wave, swell and current from Open-Meteo - Add swell params to MarineApiService request - Add swell fields to MarineHourly model - Add MarineConditions snapshot model - Add WeatherRepository.fetchCurrentConditions() (first forecast hour) - Add MainViewModel.loadConditions() + marineConditions StateFlow - Add Forecast section to instrument sheet: Curr / Wave / Swell - Populate TWS from forecast wind speed on first GPS fix - Trigger loadConditions() once on first GPS position received Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 34 +++++++++++++++++++++- .../org/terst/nav/data/api/MarineApiService.kt | 2 +- .../org/terst/nav/data/model/MarineConditions.kt | 17 +++++++++++ .../org/terst/nav/data/model/MarineResponse.kt | 3 ++ .../terst/nav/data/repository/WeatherRepository.kt | 24 +++++++++++++++ .../kotlin/org/terst/nav/ui/InstrumentHandler.kt | 28 +++++++++++++++++- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 15 ++++++++++ 7 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt (limited to 'android-app/app/src/main/kotlin') 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 c48cec2..7c0cd9e 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 @@ -182,13 +182,24 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { valueBsp = findViewById(R.id.value_bsp), valueSog = findViewById(R.id.value_sog), valueDepth = findViewById(R.id.value_depth), - valueBaro = findViewById(R.id.value_baro) + valueBaro = findViewById(R.id.value_baro), + valueCurrSpd = findViewById(R.id.value_curr_spd), + valueCurrDir = findViewById(R.id.value_curr_dir), + valueWaveHt = findViewById(R.id.value_wave_ht), + valueWaveDir = findViewById(R.id.value_wave_dir), + valueSwellHt = findViewById(R.id.value_swell_ht), + valueSwellPer = findViewById(R.id.value_swell_per) ) instrumentHandler?.updateDisplay( aws = "—", tws = "—", hdg = "—", cog = "—", bsp = "—", sog = "—", depth = "—", baro = "—" ) + instrumentHandler?.updateConditions( + currSpd = "—", currDir = "—", + waveHt = "—", waveDir = "—", + swellHt = "—", swellPer = "—" + ) } // Helper to convert dp to px @@ -263,6 +274,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } private fun observeDataSources() { + var conditionsLoaded = false lifecycleScope.launch { LocationService.locationFlow.collect { gpsData -> mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) @@ -273,6 +285,10 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { sog = "%.1f".format(Locale.getDefault(), sogKnots), cog = "%.0f°".format(Locale.getDefault(), cogDeg) ) + if (!conditionsLoaded) { + conditionsLoaded = true + viewModel.loadConditions(gpsData.latitude, gpsData.longitude) + } } } lifecycleScope.launch { @@ -282,6 +298,22 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } } } + lifecycleScope.launch { + viewModel.marineConditions.collect { c -> + if (c == null) return@collect + instrumentHandler?.updateDisplay( + tws = c.windSpeedKt?.let { "%.1f".format(Locale.getDefault(), it) } ?: "—" + ) + instrumentHandler?.updateConditions( + currSpd = c.currentSpeedKt?.let { "%.1f kn".format(Locale.getDefault(), it) } ?: "—", + currDir = c.currentDirDeg?.let { "%.0f°".format(Locale.getDefault(), it) } ?: "—", + waveHt = c.waveHeightM?.let { "%.1f m".format(Locale.getDefault(), it) } ?: "—", + waveDir = c.waveDirDeg?.let { "%.0f°".format(Locale.getDefault(), it) } ?: "—", + swellHt = c.swellHeightM?.let { "%.1f m".format(Locale.getDefault(), it) } ?: "—", + swellPer = c.swellPeriodS?.let { "%.0f s".format(Locale.getDefault(), it) } ?: "—" + ) + } + } 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/data/api/MarineApiService.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt index 67c14f8..5a7d2e2 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/api/MarineApiService.kt @@ -11,7 +11,7 @@ interface MarineApiService { @Query("latitude") latitude: Double, @Query("longitude") longitude: Double, @Query("hourly") hourly: String = - "wave_height,wave_direction,ocean_current_velocity,ocean_current_direction", + "wave_height,wave_direction,swell_wave_height,swell_wave_direction,swell_wave_period,ocean_current_velocity,ocean_current_direction", @Query("forecast_days") forecastDays: Int = 7 ): MarineResponse } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt new file mode 100644 index 0000000..3cde023 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineConditions.kt @@ -0,0 +1,17 @@ +package org.terst.nav.data.model + +/** + * Snapshot of current marine conditions derived from the first forecast hour. + * All fields are nullable — null means the model returned no data for that parameter. + */ +data class MarineConditions( + val windSpeedKt: Double?, + val windDirDeg: Double?, + val waveHeightM: Double?, + val waveDirDeg: Double?, + val swellHeightM: Double?, + val swellDirDeg: Double?, + val swellPeriodS: Double?, + val currentSpeedKt: Double?, // ocean_current_velocity converted from m/s to knots + val currentDirDeg: Double? +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt index ab9799b..cf5216d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/MarineResponse.kt @@ -15,6 +15,9 @@ data class MarineHourly( @Json(name = "time") val time: List, @Json(name = "wave_height") val waveHeight: List, @Json(name = "wave_direction") val waveDirection: List, + @Json(name = "swell_wave_height") val swellWaveHeight: List, + @Json(name = "swell_wave_direction") val swellWaveDirection: List, + @Json(name = "swell_wave_period") val swellWavePeriod: List, @Json(name = "ocean_current_velocity") val oceanCurrentVelocity: List, @Json(name = "ocean_current_direction") val oceanCurrentDirection: List ) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt index ee586a5..b70ea8c 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/repository/WeatherRepository.kt @@ -3,6 +3,7 @@ package org.terst.nav.data.repository import org.terst.nav.data.api.MarineApiService import org.terst.nav.data.api.WeatherApiService import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.MarineConditions import org.terst.nav.data.model.WindArrow class WeatherRepository( @@ -47,6 +48,29 @@ class WeatherRepository( ) } + /** + * Fetches current marine conditions (first forecast hour) for [lat]/[lon]. + * Ocean current velocity is converted from m/s to knots. + */ + suspend fun fetchCurrentConditions(lat: Double, lon: Double): Result = + runCatching { + val weather = weatherApi.getWeatherForecast(lat, lon, forecastDays = 1) + val marine = marineApi.getMarineForecast(lat, lon) + val w = weather.hourly + val m = marine.hourly + MarineConditions( + windSpeedKt = w.windspeed10m.firstOrNull(), + windDirDeg = w.winddirection10m.firstOrNull(), + waveHeightM = m.waveHeight.firstOrNull(), + waveDirDeg = m.waveDirection.firstOrNull(), + swellHeightM = m.swellWaveHeight.firstOrNull(), + swellDirDeg = m.swellWaveDirection.firstOrNull(), + swellPeriodS = m.swellWavePeriod.firstOrNull(), + currentSpeedKt = m.oceanCurrentVelocity.firstOrNull()?.let { it * 1.94384 }, + currentDirDeg = m.oceanCurrentDirection.firstOrNull() + ) + } + companion object { /** Factory using the shared ApiClient singletons. */ fun create(): WeatherRepository { diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt index 7e09756..91582c0 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/InstrumentHandler.kt @@ -13,7 +13,13 @@ class InstrumentHandler( private val valueBsp: TextView, private val valueSog: TextView, private val valueDepth: TextView, - private val valueBaro: TextView + private val valueBaro: TextView, + private val valueCurrSpd: TextView, + private val valueCurrDir: TextView, + private val valueWaveHt: TextView, + private val valueWaveDir: TextView, + private val valueSwellHt: TextView, + private val valueSwellPer: TextView ) { /** * Updates the text displays for the given instruments. Null arguments leave the current value unchanged. @@ -37,4 +43,24 @@ class InstrumentHandler( depth?.let { valueDepth.text = it } baro?.let { valueBaro.text = it } } + + /** + * Updates the forecast conditions section (Curr, Wave, Swell). + * Null arguments leave the current value unchanged. + */ + fun updateConditions( + currSpd: String? = null, + currDir: String? = null, + waveHt: String? = null, + waveDir: String? = null, + swellHt: String? = null, + swellPer: String? = null + ) { + currSpd?.let { valueCurrSpd.text = it } + currDir?.let { valueCurrDir.text = it } + waveHt?.let { valueWaveHt.text = it } + waveDir?.let { valueWaveDir.text = it } + swellHt?.let { valueSwellHt.text = it } + swellPer?.let { valueSwellPer.text = it } + } } 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 0efff52..0431f31 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 @@ -9,6 +9,7 @@ import org.terst.nav.data.api.AisHubApiService import org.terst.nav.track.TrackPoint import org.terst.nav.track.TrackRepository import org.terst.nav.data.model.ForecastItem +import org.terst.nav.data.model.MarineConditions import org.terst.nav.data.model.WindArrow import org.terst.nav.data.repository.WeatherRepository import com.squareup.moshi.Moshi @@ -40,6 +41,9 @@ class MainViewModel( private val _forecast = MutableStateFlow>(emptyList()) val forecast: StateFlow> = _forecast + private val _marineConditions = MutableStateFlow(null) + val marineConditions: StateFlow = _marineConditions.asStateFlow() + private val _aisTargets = MutableStateFlow>(emptyList()) val aisTargets: StateFlow> = _aisTargets.asStateFlow() @@ -88,6 +92,17 @@ class MainViewModel( .create(AisHubApiService::class.java) } + /** + * Fetches current conditions snapshot for [lat]/[lon] and exposes it via [marineConditions]. + * Silently ignored on network failure — the UI keeps showing dashes. + */ + fun loadConditions(lat: Double, lon: Double) { + viewModelScope.launch { + repository.fetchCurrentConditions(lat, lon) + .onSuccess { _marineConditions.value = it } + } + } + /** * Fetch weather and marine data for [lat]/[lon] in parallel. * Called once the device location is known. -- cgit v1.2.3