timklge de009454b8
Forecast weather along route in fixed intervals if route is loaded (#52)
* Prototype simplified weather forecast along route

* Reset last known route progression on route change

* Add forecast distance per hour setting

* Only decode response as gzip if gzipped

* Bump version to 1.3
2025-03-02 12:46:40 +01:00

214 lines
8.3 KiB
Kotlin

package de.timklge.karooheadwind
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.HeadwindStats
import de.timklge.karooheadwind.screens.HeadwindWidgetSettings
import de.timklge.karooheadwind.screens.WindUnit
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
val settingsKey = stringPreferencesKey("settings")
val widgetSettingsKey = stringPreferencesKey("widgetSettings")
val currentDataKey = stringPreferencesKey("currentForecasts")
val statsKey = stringPreferencesKey("stats")
val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition")
suspend fun saveSettings(context: Context, settings: HeadwindSettings) {
context.dataStore.edit { t ->
t[settingsKey] = Json.encodeToString(settings)
}
}
suspend fun saveWidgetSettings(context: Context, settings: HeadwindWidgetSettings) {
context.dataStore.edit { t ->
t[widgetSettingsKey] = Json.encodeToString(settings)
}
}
suspend fun saveStats(context: Context, stats: HeadwindStats) {
context.dataStore.edit { t ->
t[statsKey] = Json.encodeToString(stats)
}
}
@Serializable
data class WeatherDataResponse(val data: OpenMeteoCurrentWeatherResponse, val requestedPosition: GpsCoordinates)
suspend fun saveCurrentData(context: Context, forecast: List<WeatherDataResponse>) {
context.dataStore.edit { t ->
t[currentDataKey] = Json.encodeToString(forecast)
}
}
suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinates) {
Log.i(KarooHeadwindExtension.TAG, "Saving last known position: $gpsCoordinates")
try {
context.dataStore.edit { t ->
t[lastKnownPositionKey] = Json.encodeToString(gpsCoordinates)
}
} catch(e: Throwable){
Log.e(KarooHeadwindExtension.TAG, "Failed to save last known position", e)
}
}
fun Context.streamWidgetSettings(): Flow<HeadwindWidgetSettings> {
return dataStore.data.map { settingsJson ->
try {
if (settingsJson.contains(widgetSettingsKey)){
jsonWithUnknownKeys.decodeFromString<HeadwindWidgetSettings>(settingsJson[widgetSettingsKey]!!)
} else {
jsonWithUnknownKeys.decodeFromString<HeadwindWidgetSettings>(HeadwindWidgetSettings.defaultWidgetSettings)
}
} catch(e: Throwable){
Log.e(KarooHeadwindExtension.TAG, "Failed to read widget preferences", e)
jsonWithUnknownKeys.decodeFromString<HeadwindWidgetSettings>(HeadwindWidgetSettings.defaultWidgetSettings)
}
}.distinctUntilChanged()
}
fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<HeadwindSettings> {
return dataStore.data.map { settingsJson ->
try {
if (settingsJson.contains(settingsKey)){
jsonWithUnknownKeys.decodeFromString<HeadwindSettings>(settingsJson[settingsKey]!!)
} else {
val defaultSettings = jsonWithUnknownKeys.decodeFromString<HeadwindSettings>(HeadwindSettings.defaultSettings)
val preferredUnits = karooSystemService.streamUserProfile().first().preferredUnit
defaultSettings.copy(
windUnit = if (preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR,
)
}
} catch(e: Throwable){
Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e)
jsonWithUnknownKeys.decodeFromString<HeadwindSettings>(HeadwindSettings.defaultSettings)
}
}.distinctUntilChanged()
}
data class UpcomingRoute(val distanceAlongRoute: Double, val routePolyline: LineString, val routeLength: Double)
fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> {
val distanceToDestinationStream = flow {
emit(null)
streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION)
.map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
.filter { it != 0.0 } // FIXME why is 0 sometimes emitted if no route is loaded?
.collect { emit(it) }
}
var lastKnownDistanceAlongRoute = 0.0
var lastKnownRoutePolyline: LineString? = null
val navigationStateStream = streamNavigationState()
.map { it.state as? OnNavigationState.NavigationState.NavigatingRoute }
.map { navigationState ->
navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) }
}
.combine(distanceToDestinationStream) { routePolyline, distanceToDestination ->
if (routePolyline != null){
val length = TurfMeasurement.length(routePolyline, TurfConstants.UNIT_METERS)
if (routePolyline != lastKnownRoutePolyline){
lastKnownDistanceAlongRoute = 0.0
}
val distanceAlongRoute = distanceToDestination?.let { toDest -> length - toDest } ?: lastKnownDistanceAlongRoute
lastKnownDistanceAlongRoute = distanceAlongRoute
lastKnownRoutePolyline = routePolyline
UpcomingRoute(distanceAlongRoute, routePolyline, length)
} else {
null
}
}
return navigationStateStream
}
fun Context.streamStats(): Flow<HeadwindStats> {
return dataStore.data.map { statsJson ->
try {
jsonWithUnknownKeys.decodeFromString<HeadwindStats>(
statsJson[statsKey] ?: HeadwindStats.defaultStats
)
} catch(e: Throwable){
Log.e(KarooHeadwindExtension.TAG, "Failed to read stats", e)
jsonWithUnknownKeys.decodeFromString<HeadwindStats>(HeadwindStats.defaultStats)
}
}.distinctUntilChanged()
}
suspend fun Context.getLastKnownPosition(): GpsCoordinates? {
val settingsJson = dataStore.data.first()
try {
val lastKnownPositionString = settingsJson[lastKnownPositionKey] ?: return null
val lastKnownPosition = jsonWithUnknownKeys.decodeFromString<GpsCoordinates>(
lastKnownPositionString
)
return lastKnownPosition
} catch(e: Throwable){
Log.e(KarooHeadwindExtension.TAG, "Failed to read last known position", e)
return null
}
}
fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
return callbackFlow {
val listenerId = addConsumer { userProfile: UserProfile ->
trySendBlocking(userProfile)
}
awaitClose {
removeConsumer(listenerId)
}
}
}
fun Context.streamCurrentWeatherData(): Flow<List<WeatherDataResponse>> {
return dataStore.data.map { settingsJson ->
try {
val data = settingsJson[currentDataKey]
data?.let { d -> jsonWithUnknownKeys.decodeFromString<List<WeatherDataResponse>>(d) } ?: emptyList()
} catch (e: Throwable) {
Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e)
emptyList()
}
}.distinctUntilChanged().map { response ->
response.filter { forecast ->
forecast.data.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)
}
}
}