karoo-headwind/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt
timklge ee206a11c9
Add weather view to main menu (#54)
* Add initial weather widget to main menu

* Always forecast position with calculated distance

* Fix manifest

* Replace exit button with back button
2025-03-02 15:18:07 +01:00

274 lines
13 KiB
Kotlin

package de.timklge.karooheadwind
import android.util.Log
import androidx.compose.ui.util.fastZip
import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.KarooExtension
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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.map
import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.debounce
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.zip.GZIPInputStream
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.3") {
companion object {
const val TAG = "karoo-headwind"
}
private lateinit var karooSystem: KarooSystemService
private var updateLastKnownGpsJob: Job? = null
private var serviceJob: Job? = null
override val types by lazy {
listOf(
HeadwindDirectionDataType(karooSystem, applicationContext),
TailwindAndRideSpeedDataType(karooSystem, applicationContext),
HeadwindSpeedDataType(karooSystem, applicationContext),
WeatherDataType(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem, applicationContext),
HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(applicationContext),
CloudCoverDataType(applicationContext),
WindGustsDataType(applicationContext),
WindSpeedDataType(applicationContext),
TemperatureDataType(applicationContext),
WindDirectionDataType(karooSystem, applicationContext),
PrecipitationDataType(applicationContext),
SurfacePressureDataType(applicationContext),
SealevelPressureDataType(applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext)
)
}
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?,
val profile: UserProfile? = null, val upcomingRoute: UpcomingRoute? = null)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() {
super.onCreate()
karooSystem = KarooSystemService(applicationContext)
updateLastKnownGpsJob = CoroutineScope(Dispatchers.IO).launch {
karooSystem.updateLastKnownGps(this@KarooHeadwindExtension)
}
serviceJob = CoroutineScope(Dispatchers.IO).launch {
karooSystem.connect { connected ->
if (connected) {
Log.d(TAG, "Connected to Karoo system")
}
}
val gpsFlow = karooSystem
.getGpsCoordinateFlow(this@KarooHeadwindExtension)
.distinctUntilChanged { old, new ->
if (old != null && new != null) {
old.distanceTo(new).absoluteValue < 0.001
} else {
old == new
}
}
.debounce(Duration.ofSeconds(5))
var requestedGpsCoordinates: List<GpsCoordinates> = mutableListOf()
val settingsStream = streamSettings(karooSystem)
.filter { it.welcomeDialogAccepted }
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile?, val upcomingRoute: UpcomingRoute?)
data class StreamDataIdentity(val settings: HeadwindSettings, val gpsLat: Double?, val gpsLon: Double?, val profile: UserProfile?, val routePolyline: LineString?)
combine(settingsStream, gpsFlow, karooSystem.streamUserProfile(), karooSystem.streamUpcomingRoute()) { settings, gps, profile, upcomingRoute ->
StreamData(settings, gps, profile, upcomingRoute)
}
.distinctUntilChangedBy { StreamDataIdentity(it.settings, it.gps?.lat, it.gps?.lon, it.profile, it.upcomingRoute?.routePolyline) }
.transformLatest { value ->
while(true){
emit(value)
delay(1.hours)
}
}
.map { (settings: HeadwindSettings, gps, profile, upcomingRoute) ->
Log.d(TAG, "Acquired updated gps coordinates: $gps")
val lastKnownStats = try {
streamStats().first()
} catch(e: Exception){
Log.e(TAG, "Failed to read stats", e)
HeadwindStats()
}
if (gps == null){
error("No GPS coordinates available")
}
if (upcomingRoute != null){
val positionOnRoute = upcomingRoute.distanceAlongRoute
Log.i(TAG, "Position on route: ${positionOnRoute}m")
val distancePerHour = settings.getForecastMetersPerHour(profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL).toDouble()
val msSinceFullHour = let {
val now = LocalDateTime.now()
val startOfHour = now.truncatedTo(ChronoUnit.HOURS)
ChronoUnit.MILLIS.between(startOfHour, now)
}
val msToNextFullHour = (1_000 * 60 * 60) - msSinceFullHour
val calculatedDistanceToNextFullHour = ((msToNextFullHour / (1_000.0 * 60 * 60)) * distancePerHour).coerceIn(0.0, distancePerHour)
Log.d(TAG, "Minutes to next full hour: ${msToNextFullHour / 1000 / 60}, Distance to next full hour: ${(calculatedDistanceToNextFullHour / 1000).roundToInt()}km")
requestedGpsCoordinates = buildList {
add(gps)
var currentPosition = positionOnRoute + calculatedDistanceToNextFullHour
var lastRequestedPosition = currentPosition
while (currentPosition < upcomingRoute.routeLength && size < 10) {
val point = TurfMeasurement.along(
upcomingRoute.routePolyline,
currentPosition,
TurfConstants.UNIT_METERS
)
add(
GpsCoordinates(
point.latitude(),
point.longitude(),
distanceAlongRoute = currentPosition
)
)
lastRequestedPosition = currentPosition
currentPosition += distancePerHour
}
if (upcomingRoute.routeLength > lastRequestedPosition + 5_000) {
val point = TurfMeasurement.along(
upcomingRoute.routePolyline,
upcomingRoute.routeLength,
TurfConstants.UNIT_METERS
)
add(
GpsCoordinates(
point.latitude(),
point.longitude(),
distanceAlongRoute = upcomingRoute.routeLength
)
)
}
}
} else {
requestedGpsCoordinates = mutableListOf(gps)
}
val response = karooSystem.makeOpenMeteoHttpRequest(requestedGpsCoordinates, settings, profile)
if (response.error != null){
try {
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis())
launch { saveStats(this@KarooHeadwindExtension, stats) }
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
}
error("HTTP request failed: ${response.error}")
} else {
try {
val stats = lastKnownStats.copy(
lastSuccessfulWeatherRequest = System.currentTimeMillis(),
lastSuccessfulWeatherPosition = gps
)
launch { saveStats(this@KarooHeadwindExtension, stats) }
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
}
}
response
}.retry(Long.MAX_VALUE) { e ->
Log.w(TAG, "Failed to get weather data", e)
delay(1.minutes); true
}.collect { response ->
try {
val inputStream = java.io.ByteArrayInputStream(response.body ?: ByteArray(0))
val lowercaseHeaders = response.headers.map { (k: String, v: String) -> k.lowercase() to v.lowercase() }.toMap()
val isGzippedResponse = lowercaseHeaders["content-encoding"]?.contains("gzip") == true
val responseString = if(isGzippedResponse){
val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) }
gzipStream.use { stream -> String(stream.readBytes()) }
} else {
inputStream.use { stream -> String(stream.readBytes()) }
}
if (requestedGpsCoordinates.size == 1){
val weatherData = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseString)
val data = WeatherDataResponse(weatherData, requestedGpsCoordinates.single())
saveCurrentData(applicationContext, listOf(data))
Log.d(TAG, "Got updated weather info: $data")
} else {
val weatherData = jsonWithUnknownKeys.decodeFromString<List<OpenMeteoCurrentWeatherResponse>>(responseString)
val data = weatherData.fastZip(requestedGpsCoordinates) { weather, gps -> WeatherDataResponse(weather, gps) }
saveCurrentData(applicationContext, data)
Log.d(TAG, "Got updated weather info: $data")
}
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
} catch(e: Exception){
Log.e(TAG, "Failed to read current weather data", e)
}
}
}
}
override fun onDestroy() {
serviceJob?.cancel()
serviceJob = null
updateLastKnownGpsJob?.cancel()
updateLastKnownGpsJob = null
karooSystem.disconnect()
super.onDestroy()
}
}