Use interpolated forecast data to update current weather data, refactor weather provider code (#83)

* Use interpolated forecast data to update current weather data, refactor weather provider code

* Fix interpolation between locations

* Fix copypaste error message for open meteo http

* Fix error display

* Show interpolated time

* Fix position forecasts after refactoring

* fix #84: Add lerpAngle

* fix #85: Fix weather widget shows wind direction rotated by 180 degrees

* Make red background color slightly lighter to improve contrast
This commit is contained in:
timklge 2025-04-07 21:47:37 +02:00 committed by GitHub
parent 90eb0a0821
commit efaa8efc2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1087 additions and 861 deletions

View File

@ -5,33 +5,41 @@ import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.mapbox.geojson.LineString
import com.mapbox.geojson.Point
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.minutes
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
val settingsKey = stringPreferencesKey("settings")
val widgetSettingsKey = stringPreferencesKey("widgetSettings")
val currentDataKey = stringPreferencesKey("currentForecasts")
val currentDataKey = stringPreferencesKey("currentForecastsUnified")
val statsKey = stringPreferencesKey("stats")
val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition")
@ -53,12 +61,9 @@ suspend fun saveStats(context: Context, stats: HeadwindStats) {
}
}
@Serializable
data class WeatherDataResponse(val data: OpenMeteoCurrentWeatherResponse, val requestedPosition: GpsCoordinates)
suspend fun saveCurrentData(context: Context, forecast: List<WeatherDataResponse>) {
suspend fun saveCurrentData(context: Context, response: WeatherDataResponse) {
context.dataStore.edit { t ->
t[currentDataKey] = Json.encodeToString(forecast)
t[currentDataKey] = Json.encodeToString(response)
}
}
@ -114,13 +119,9 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow<Headwin
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)
val distanceToDestinationStream = streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION)
.map { (it as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION) }
.collect { emit(it) }
}
.distinctUntilChanged()
var lastKnownDistanceAlongRoute = 0.0
var lastKnownRoutePolyline: LineString? = null
@ -130,6 +131,7 @@ fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> {
.map { navigationState ->
navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) }
}
.distinctUntilChanged()
.combine(distanceToDestinationStream) { routePolyline, distanceToDestination ->
Log.d(KarooHeadwindExtension.TAG, "Route polyline size: ${routePolyline?.coordinates()?.size}, distance to destination: $distanceToDestination")
if (routePolyline != null){
@ -190,18 +192,191 @@ fun KarooSystemService.streamUserProfile(): Flow<UserProfile> {
}
}
fun Context.streamCurrentWeatherData(): Flow<List<WeatherDataResponse>> {
fun Context.streamCurrentForecastWeatherData(): Flow<WeatherDataResponse?> {
return dataStore.data.map { settingsJson ->
try {
val data = settingsJson[currentDataKey]
data?.let { d -> jsonWithUnknownKeys.decodeFromString<List<WeatherDataResponse>>(d) } ?: emptyList()
data?.let { d -> jsonWithUnknownKeys.decodeFromString<WeatherDataResponse>(d) }
} catch (e: Throwable) {
Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e)
emptyList()
null
}
}.distinctUntilChanged()
}
fun lerpNullable(
start: Double?,
end: Double?,
factor: Double
): Double? {
if (start == null && end == null) return null
if (start == null) return end
if (end == null) return start
return start + (end - start) * factor
}
/**
* Linearly interpolates between two angles in degrees
*
* @param start Starting angle in degrees [0-360]
* @param end Ending angle in degrees [0-360]
* @param factor Interpolation factor [0-1]
* @return Interpolated angle in degrees [0-360]
*/
fun lerpAngle(
start: Double,
end: Double,
factor: Double
): Double {
val normalizedStart = start % 360.0
val normalizedEnd = end % 360.0
var diff = normalizedEnd - normalizedStart
if (diff > 180.0) {
diff -= 360.0
} else if (diff < -180.0) {
diff += 360.0
}
var result = normalizedStart + diff * factor
if (result < 0) {
result += 360.0
} else if (result >= 360.0) {
result -= 360.0
}
return result
}
fun lerpWeather(
start: WeatherData,
end: WeatherData,
factor: Double
): WeatherData {
val closestWeatherData = if (factor < 0.5) start else end
return WeatherData(
time = (start.time + (end.time - start.time) * factor).toLong(),
temperature = start.temperature + (end.temperature - start.temperature) * factor,
relativeHumidity = lerpNullable(start.relativeHumidity, end.relativeHumidity, factor),
precipitation = start.precipitation + (end.precipitation - start.precipitation) * factor,
cloudCover = lerpNullable(start.cloudCover, end.cloudCover, factor),
surfacePressure = lerpNullable(start.surfacePressure, end.surfacePressure, factor),
sealevelPressure = lerpNullable(start.sealevelPressure, end.sealevelPressure, factor),
windSpeed = start.windSpeed + (end.windSpeed - start.windSpeed) * factor,
windDirection = lerpAngle(start.windDirection, end.windDirection, factor),
windGusts = start.windGusts + (end.windGusts - start.windGusts) * factor,
weatherCode = closestWeatherData.weatherCode,
isForecast = closestWeatherData.isForecast
)
}
fun lerpWeatherTime(
weatherData: List<WeatherData>?,
currentWeatherData: WeatherData
): WeatherData {
val now = System.currentTimeMillis()
val nextWeatherForecastData = weatherData?.find { forecast -> forecast.time * 1000 >= now }
val previousWeatherForecastData = weatherData?.findLast { forecast -> forecast.time * 1000 < now }
val interpolateStartWeatherData = previousWeatherForecastData ?: currentWeatherData
val interpolateEndWeatherData = nextWeatherForecastData ?: interpolateStartWeatherData
val lerpFactor = ((now - (interpolateStartWeatherData.time * 1000)).toDouble() / (interpolateEndWeatherData.time * 1000 - (interpolateStartWeatherData.time * 1000)).absoluteValue).coerceIn(0.0, 1.0)
return lerpWeather(
start = interpolateStartWeatherData,
end = interpolateEndWeatherData,
factor = lerpFactor
)
}
@OptIn(ExperimentalCoroutinesApi::class)
fun Context.streamCurrentWeatherData(karooSystemService: KarooSystemService): Flow<WeatherData?> {
val locationFlow = flow {
emit(null)
emitAll(karooSystemService.getGpsCoordinateFlow(this@streamCurrentWeatherData))
}
return dataStore.data.map { settingsJson ->
try {
val data = settingsJson[currentDataKey]
data?.let { d -> jsonWithUnknownKeys.decodeFromString<WeatherDataResponse>(d) }
} catch (e: Throwable) {
Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e)
null
}
}.combine(locationFlow) {
weatherData, location -> weatherData to location
}.distinctUntilChanged()
.flatMapLatest { (weatherData, location) ->
flow {
if (!weatherData?.data.isNullOrEmpty()) {
while(true){
// Get weather for closest position
val weatherDataForCurrentPosition = if (location == null || weatherData?.data?.size == 1) weatherData?.data?.first() else {
val weatherDatas = weatherData?.data?.sortedBy { data ->
TurfMeasurement.distance(
Point.fromLngLat(location.lon, location.lat),
Point.fromLngLat(data.coords.lon, data.coords.lat),
TurfConstants.UNIT_METERS
)
}!!.take(2)
val location1 = weatherDatas[0]
val location2 = weatherDatas[1]
val distanceToLocation1 = TurfMeasurement.distance(
Point.fromLngLat(location.lon, location.lat),
Point.fromLngLat(location1.coords.lon, location1.coords.lat),
TurfConstants.UNIT_METERS
)
val distanceToLocation2 = TurfMeasurement.distance(
Point.fromLngLat(location.lon, location.lat),
Point.fromLngLat(location2.coords.lon, location2.coords.lat),
TurfConstants.UNIT_METERS
)
val lerpFactor = (distanceToLocation1 / (distanceToLocation1 + distanceToLocation2)).coerceIn(0.0, 1.0)
val interpolatedWeatherData = lerpWeather(
start = location1.current,
end = location2.current,
factor = lerpFactor
)
val interpolatedForecasts = location1.forecasts?.mapIndexed { index, forecast ->
val forecast2 = location2.forecasts?.get(index) ?: error("Mismatched forecast lengths")
val interpolatedForecast = lerpWeather(
start = forecast,
end = forecast2,
factor = lerpFactor
)
interpolatedForecast
}
WeatherDataForLocation(
coords = location,
current = interpolatedWeatherData,
forecasts = interpolatedForecasts
)
}
if (weatherDataForCurrentPosition != null) {
emit(lerpWeatherTime(
weatherDataForCurrentPosition.forecasts,
weatherDataForCurrentPosition.current
))
} else {
emit(null)
}
delay(1.minutes)
}
} else {
emit(null)
}
}.distinctUntilChanged().map { response ->
response.filter { forecast ->
forecast.data.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)
}
}
}

View File

@ -3,8 +3,8 @@ package de.timklge.karooheadwind
import android.content.Context
import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -13,10 +13,8 @@ import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
sealed class HeadingResponse {
data object NoGps: HeadingResponse()
data object NoWeatherData: HeadingResponse()
@ -24,14 +22,14 @@ sealed class HeadingResponse {
}
fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingResponse> {
val currentWeatherData = context.streamCurrentWeatherData()
val currentWeatherData = context.streamCurrentWeatherData(this)
return getHeadingFlow(context)
.combine(currentWeatherData) { bearing, data -> bearing to data }
.map { (bearing, data) ->
when {
bearing is HeadingResponse.Value && data.isNotEmpty() -> {
val windBearing = data.first().data.current.windDirection + 180
bearing is HeadingResponse.Value && data != null -> {
val windBearing = data.windDirection + 180
val diff = signedAngleDifference(bearing.diff, windBearing)
Log.d(KarooHeadwindExtension.TAG, "Wind bearing: Heading $bearing vs wind $windBearing => $diff")
@ -39,7 +37,7 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
HeadingResponse.Value(diff)
}
bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps
bearing is HeadingResponse.NoWeatherData || data.isEmpty() -> HeadingResponse.NoWeatherData
bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData
else -> bearing
}
}

View File

@ -1,7 +1,6 @@
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
@ -26,6 +25,7 @@ import de.timklge.karooheadwind.datatypes.WindDirectionDataType
import de.timklge.karooheadwind.datatypes.WindForecastDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import de.timklge.karooheadwind.weatherprovider.WeatherProviderFactory
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.KarooExtension
import io.hammerhead.karooext.models.UserProfile
@ -45,11 +45,9 @@ 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
@ -73,15 +71,15 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
WeatherDataType(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem),
HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(applicationContext),
CloudCoverDataType(applicationContext),
WindGustsDataType(applicationContext),
WindSpeedDataType(applicationContext),
TemperatureDataType(applicationContext),
RelativeHumidityDataType(karooSystem, applicationContext),
CloudCoverDataType(karooSystem, applicationContext),
WindGustsDataType(karooSystem, applicationContext),
WindSpeedDataType(karooSystem, applicationContext),
TemperatureDataType(karooSystem, applicationContext),
WindDirectionDataType(karooSystem, applicationContext),
PrecipitationDataType(applicationContext),
SurfacePressureDataType(applicationContext),
SealevelPressureDataType(applicationContext),
PrecipitationDataType(karooSystem, applicationContext),
SurfacePressureDataType(karooSystem, applicationContext),
SealevelPressureDataType(karooSystem, applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext),
TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem),
@ -121,10 +119,9 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
}
.debounce(Duration.ofSeconds(5))
var requestedGpsCoordinates: List<GpsCoordinates> = mutableListOf()
var requestedGpsCoordinates: List<GpsCoordinates> = emptyList()
val settingsStream = streamSettings(karooSystem)
.filter { it.welcomeDialogAccepted }
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?)
@ -211,51 +208,30 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
requestedGpsCoordinates = mutableListOf(gps)
}
val response = karooSystem.makeOpenMeteoHttpRequest(requestedGpsCoordinates, settings, profile)
if (response.error != null){
try {
val response = try {
WeatherProviderFactory.makeWeatherRequest(karooSystem, requestedGpsCoordinates, settings, profile)
} catch(e: Throwable){
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis())
launch { saveStats(this@KarooHeadwindExtension, stats) }
launch {
try {
saveStats(this@KarooHeadwindExtension, stats)
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
}
error("HTTP request failed: ${response.error}")
} else {
try {
val responseBody = response.body?.let { String(it) }
var weatherDataProvider: WeatherDataProvider? = null
try {
if (responseBody != null) {
if (responseBody.trim().startsWith("[")) {
val responseArray =
jsonWithUnknownKeys.decodeFromString<List<OpenMeteoCurrentWeatherResponse>>(
responseBody
)
weatherDataProvider = responseArray.firstOrNull()?.provider
} else {
val responseObject =
jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(
responseBody
)
weatherDataProvider = responseObject.provider
}
}
} catch (e: Exception) {
Log.e(TAG, "Error decoding provider", e)
throw e
}
try {
val stats = lastKnownStats.copy(
lastSuccessfulWeatherRequest = System.currentTimeMillis(),
lastSuccessfulWeatherPosition = gps,
lastSuccessfulWeatherProvider = weatherDataProvider
lastSuccessfulWeatherProvider = response.provider
)
launch { saveStats(this@KarooHeadwindExtension, stats) }
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
}
}
response
}.retry(Long.MAX_VALUE) { e ->
@ -263,30 +239,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
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")
}
saveCurrentData(applicationContext, response)
Log.d(TAG, "Got updated weather info: $response")
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
} catch(e: Exception){

View File

@ -1,85 +0,0 @@
package de.timklge.karooheadwind
import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.OnHttpResponse
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@OptIn(FlowPreview::class)
suspend fun KarooSystemService.originalOpenMeteoRequest(gpsCoordinates: List<GpsCoordinates>, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete {
val precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH
val temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT
return callbackFlow {
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12
val lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) }
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")
val listenerId = addConsumer(
OnHttpResponse.MakeHttpRequest(
"GET",
url,
waitForConnection = false,
headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG, "Accept-Encoding" to "gzip"),
),
onEvent = { event: OnHttpResponse ->
if (event.state is HttpResponseState.Complete){
Log.d(KarooHeadwindExtension.TAG, "Http response received")
trySend(event.state as HttpResponseState.Complete)
close()
}
},
onError = { err ->
Log.d(KarooHeadwindExtension.TAG, "Http error: $err")
close(RuntimeException(err))
})
awaitClose {
removeConsumer(listenerId)
}
}.timeout(30.seconds).catch { e: Throwable ->
if (e is TimeoutCancellationException){
emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout"))
} else {
throw e
}
}.single()
}
@OptIn(FlowPreview::class)
suspend fun KarooSystemService.makeOpenMeteoHttpRequest(
gpsCoordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): HttpResponseState.Complete {
val provider = WeatherProviderFactory.getProvider(settings)
val response = provider.getWeatherData(this, gpsCoordinates, settings, profile)
if (response.error != null) {
if (provider is OpenWeatherMapProvider) {
WeatherProviderFactory.handleOpenWeatherMapFailure()
}
} else {
if (provider is OpenWeatherMapProvider) {
WeatherProviderFactory.resetOpenWeatherMapFailures()
} else if (provider is OpenMeteoProvider) {
WeatherProviderFactory.handleOpenMeteoSuccess()
}
}
return response
}

View File

@ -1,64 +0,0 @@
package de.timklge.karooheadwind
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpenMeteoData(
val time: Long, val interval: Int,
@SerialName("temperature_2m") val temperature: Double,
@SerialName("relative_humidity_2m") val relativeHumidity: Int,
@SerialName("precipitation") val precipitation: Double,
@SerialName("cloud_cover") val cloudCover: Int,
@SerialName("surface_pressure") val surfacePressure: Double,
@SerialName("pressure_msl") val sealevelPressure: Double? = null,
@SerialName("wind_speed_10m") val windSpeed: Double,
@SerialName("wind_direction_10m") val windDirection: Double,
@SerialName("wind_gusts_10m") val windGusts: Double,
@SerialName("weather_code") val weatherCode: Int,
)
@Serializable
data class OpenMeteoForecastData(
@SerialName("time") val time: List<Long>,
@SerialName("temperature_2m") val temperature: List<Double>,
@SerialName("precipitation_probability") val precipitationProbability: List<Int>,
@SerialName("precipitation") val precipitation: List<Double>,
@SerialName("weather_code") val weatherCode: List<Int>,
@SerialName("wind_speed_10m") val windSpeed: List<Double>,
@SerialName("wind_direction_10m") val windDirection: List<Double>,
@SerialName("wind_gusts_10m") val windGusts: List<Double>,
)
enum class WeatherInterpretation {
CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN;
companion object {
// WMO weather interpretation codes (WW)
fun fromWeatherCode(code: Int): WeatherInterpretation {
return when(code){
0 -> CLEAR
1, 2, 3 -> CLOUDY
45, 48, 61, 63, 65, 66, 67, 80, 81, 82 -> RAINY
71, 73, 75, 77, 85, 86 -> SNOWY
51, 53, 55, 56, 57 -> DRIZZLE
95, 96, 99 -> THUNDERSTORM
else -> UNKNOWN
}
}
fun getKnownWeatherCodes(): Set<Int> = setOf(0, 1, 2, 3, 45, 48, 61, 63, 65, 66, 67, 80, 81, 82, 71, 73, 75, 77, 85, 86, 51, 53, 55, 56, 57, 95, 96, 99)
}
}
@Serializable
data class OpenMeteoCurrentWeatherResponse(
val current: OpenMeteoData,
val latitude: Double,
val longitude: Double,
val timezone: String,
val elevation: Double,
@SerialName("utc_offset_seconds") val utfOffsetSeconds: Int,
@SerialName("hourly") val forecastData: OpenMeteoForecastData?,
val provider: WeatherDataProvider? = null
)

View File

@ -1,39 +0,0 @@
package de.timklge.karooheadwind
import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.UserProfile
class OpenMeteoProvider : WeatherProvider {
override suspend fun getWeatherData(
karooSystem: KarooSystemService,
coordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): HttpResponseState.Complete {
val response = karooSystem.originalOpenMeteoRequest(coordinates, settings, profile)
if (response.error != null || response.body == null) {
return response
}
try {
val responseBody = response.body?.let { String(it) }
?: return response
val weatherData = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseBody)
val updatedData = weatherData.copy(provider = WeatherDataProvider.OPEN_METEO)
val updatedJson = jsonWithUnknownKeys.encodeToString(updatedData)
return response.copy(body = updatedJson.toByteArray())
} catch (e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error processing OpenMeteo response", e)
return response
}
}
}

View File

@ -1,248 +0,0 @@
package de.timklge.karooheadwind
import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.OnHttpResponse
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.seconds
@Serializable
data class OneCallResponse(
val lat: Double,
val lon: Double,
val timezone: String,
@SerialName("timezone_offset") val timezoneOffset: Int,
val current: CurrentWeather,
val hourly: List<HourlyForecast>
)
@Serializable
data class CurrentWeather(
val dt: Long,
val sunrise: Long,
val sunset: Long,
val temp: Double,
val feels_like: Double,
val pressure: Int,
val humidity: Int,
val clouds: Int,
val visibility: Int,
val wind_speed: Double,
val wind_deg: Int,
val wind_gust: Double? = null,
val rain: Rain? = null,
val snow: Snow? = null,
val weather: List<Weather>
)
@Serializable
data class HourlyForecast(
val dt: Long,
val temp: Double,
val feels_like: Double,
val pressure: Int,
val humidity: Int,
val clouds: Int,
val visibility: Int,
val wind_speed: Double,
val wind_deg: Int,
val wind_gust: Double? = null,
val pop: Double,
val rain: Rain? = null,
val snow: Snow? = null,
val weather: List<Weather>
)
@Serializable
data class Weather(
val id: Int,
val main: String,
val description: String,
val icon: String
)
@Serializable
data class Rain(
@SerialName("1h") val h1: Double = 0.0,
@SerialName("3h") val h3: Double = 0.0
)
@Serializable
data class Snow(
@SerialName("1h") val h1: Double = 0.0,
@SerialName("3h") val h3: Double = 0.0
)
class OpenWeatherMapProvider(private val apiKey: String) : WeatherProvider {
override suspend fun getWeatherData(
service: KarooSystemService,
coordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): HttpResponseState.Complete {
val response = makeOpenWeatherMapRequest(service, coordinates, apiKey)
if (response.error != null || response.body == null) {
return response
}
try {
val responseBody = response.body?.let { String(it) }
?: throw Exception("Null response from OpenWeatherMap")
if (coordinates.size > 1) {
val responses = mutableListOf<OpenMeteoCurrentWeatherResponse>()
val oneCallResponse = jsonWithUnknownKeys.decodeFromString<OneCallResponse>(responseBody)
responses.add(convertToOpenMeteoFormat(oneCallResponse))
val finalBody = jsonWithUnknownKeys.encodeToString(responses)
return HttpResponseState.Complete(
statusCode = response.statusCode,
headers = response.headers,
body = finalBody.toByteArray(),
error = null
)
} else {
val oneCallResponse = jsonWithUnknownKeys.decodeFromString<OneCallResponse>(responseBody)
val convertedResponse = convertToOpenMeteoFormat(oneCallResponse)
val finalBody = jsonWithUnknownKeys.encodeToString(convertedResponse)
return HttpResponseState.Complete(
statusCode = response.statusCode,
headers = response.headers,
body = finalBody.toByteArray(),
error = null
)
}
} catch (e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error OpenWeatherMap answer processing", e)
return HttpResponseState.Complete(
statusCode = 500,
headers = mapOf(),
body = null,
error = "Error processing data: ${e.message}"
)
}
}
private fun convertToOpenMeteoFormat(oneCallResponse: OneCallResponse): OpenMeteoCurrentWeatherResponse {
val current = OpenMeteoData(
time = oneCallResponse.current.dt,
interval = 3600,
temperature = oneCallResponse.current.temp,
relativeHumidity = oneCallResponse.current.humidity,
precipitation = oneCallResponse.current.rain?.h1 ?: 0.0,
cloudCover = oneCallResponse.current.clouds,
surfacePressure = oneCallResponse.current.pressure.toDouble(),
sealevelPressure = oneCallResponse.current.pressure.toDouble(),
windSpeed = oneCallResponse.current.wind_speed,
windDirection = oneCallResponse.current.wind_deg.toDouble(),
windGusts = oneCallResponse.current.wind_gust ?: oneCallResponse.current.wind_speed,
weatherCode = convertWeatherCodeToOpenMeteo(oneCallResponse.current.weather.firstOrNull()?.id ?: 800)
)
val forecastHours = minOf(12, oneCallResponse.hourly.size)
val hourlyForecasts = oneCallResponse.hourly.take(forecastHours)
val forecastData = OpenMeteoForecastData(
time = hourlyForecasts.map { it.dt },
temperature = hourlyForecasts.map { it.temp },
precipitationProbability = hourlyForecasts.map { (it.pop * 100).toInt() },
precipitation = hourlyForecasts.map { it.rain?.h1 ?: 0.0 },
weatherCode = hourlyForecasts.map { convertWeatherCodeToOpenMeteo(it.weather.firstOrNull()?.id ?: 800) },
windSpeed = hourlyForecasts.map { it.wind_speed },
windDirection = hourlyForecasts.map { it.wind_deg.toDouble() },
windGusts = hourlyForecasts.map { it.wind_gust ?: it.wind_speed }
)
return OpenMeteoCurrentWeatherResponse(
current = current,
latitude = oneCallResponse.lat,
longitude = oneCallResponse.lon,
timezone = oneCallResponse.timezone,
elevation = 0.0,
utfOffsetSeconds = oneCallResponse.timezoneOffset,
forecastData = forecastData,
provider = WeatherDataProvider.OPEN_WEATHER_MAP
)
}
private fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int {
// Mapping OpenWeatherMap to WMO OpenMeteo
return when (owmCode) {
in 200..299 -> 95 // Thunderstorm
in 300..399 -> 51 // Drizzle
in 500..599 -> 61 // Rain
in 600..699 -> 71 // Snow
800 -> 0 // Clear
in 801..804 -> 1 // Cloudy
else -> 0
}
}
@OptIn(FlowPreview::class)
private suspend fun makeOpenWeatherMapRequest(
service: KarooSystemService,
coordinates: List<GpsCoordinates>,
apiKey: String
): HttpResponseState.Complete {
return callbackFlow {
val coordinate = coordinates.first()
// URL API 3.0 with onecall endpoint
val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" +
"&appid=$apiKey&units=metric&exclude=minutely,daily,alerts"
Log.d(KarooHeadwindExtension.TAG, "Http request to OpenWeatherMap API 3.0: $url")
val listenerId = service.addConsumer(
OnHttpResponse.MakeHttpRequest(
"GET",
url,
waitForConnection = false,
headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG)
),
onEvent = { event: OnHttpResponse ->
if (event.state is HttpResponseState.Complete) {
Log.d(KarooHeadwindExtension.TAG, "Http response received from OpenWeatherMap")
trySend(event.state as HttpResponseState.Complete)
close()
}
},
onError = { err ->
Log.e(KarooHeadwindExtension.TAG, "Http error: $err")
close(RuntimeException(err))
}
)
awaitClose {
service.removeConsumer(listenerId)
}
}.timeout(30.seconds).catch { e: Throwable ->
if (e is TimeoutCancellationException) {
emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout"))
} else {
throw e
}
}.single()
}
}

View File

@ -1,71 +0,0 @@
package de.timklge.karooheadwind
import android.util.Log
import java.time.LocalDate
object WeatherProviderFactory {
private var openWeatherMapConsecutiveFailures = 0
private var openWeatherMapTotalFailures = 0
private var openMeteoSuccessfulAfterFailures = false
private var fallbackUntilDate: LocalDate? = null
private const val MAX_FAILURES_BEFORE_TEMP_FALLBACK = 3
private const val MAX_FAILURES_BEFORE_DAILY_FALLBACK = 20
fun getProvider(settings: HeadwindSettings): WeatherProvider {
val currentDate = LocalDate.now()
if (fallbackUntilDate != null && !currentDate.isAfter(fallbackUntilDate)) {
Log.d(KarooHeadwindExtension.TAG, "Using diary fallback OpenMeteo until $fallbackUntilDate")
return OpenMeteoProvider()
}
if (settings.weatherProvider == WeatherDataProvider.OPEN_WEATHER_MAP &&
openWeatherMapConsecutiveFailures >= MAX_FAILURES_BEFORE_TEMP_FALLBACK) {
openWeatherMapConsecutiveFailures = 0
Log.d(KarooHeadwindExtension.TAG, "Using temporary fallback OpenMeteo")
return OpenMeteoProvider()
}
return when (settings.weatherProvider) {
WeatherDataProvider.OPEN_METEO -> OpenMeteoProvider()
WeatherDataProvider.OPEN_WEATHER_MAP -> OpenWeatherMapProvider(settings.openWeatherMapApiKey)
}
}
fun handleOpenWeatherMapFailure() {
openWeatherMapConsecutiveFailures++
openWeatherMapTotalFailures++
Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap failed $openWeatherMapConsecutiveFailures times consecutive, $openWeatherMapTotalFailures total times")
if (openWeatherMapTotalFailures >= MAX_FAILURES_BEFORE_DAILY_FALLBACK && openMeteoSuccessfulAfterFailures) {
fallbackUntilDate = LocalDate.now()
Log.d(KarooHeadwindExtension.TAG, "Activated daily fallback OpenMeteo until $fallbackUntilDate")
}
}
fun handleOpenMeteoSuccess() {
openMeteoSuccessfulAfterFailures = openWeatherMapTotalFailures > 0
}
fun resetOpenWeatherMapFailures() {
openWeatherMapConsecutiveFailures = 0
}
fun resetAllFailures() {
openWeatherMapConsecutiveFailures = 0
openWeatherMapTotalFailures = 0
openMeteoSuccessfulAfterFailures = false
fallbackUntilDate = null
}
}
data class ProviderState(
val provider: WeatherDataProvider,
var consecutiveFailures: Int = 0
)

View File

@ -3,8 +3,9 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.streamCurrentWeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.models.DataPoint
@ -16,21 +17,23 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
abstract class BaseDataType(
private val karooSystemService: KarooSystemService,
private val applicationContext: Context,
dataTypeId: String
) : DataTypeImpl("karoo-headwind", dataTypeId) {
abstract fun getValue(data: OpenMeteoCurrentWeatherResponse): Double
abstract fun getValue(data: WeatherData): Double?
override fun startStream(emitter: Emitter<StreamState>) {
Log.d(KarooHeadwindExtension.TAG, "start $dataTypeId stream")
val job = CoroutineScope(Dispatchers.IO).launch {
val currentWeatherData = applicationContext.streamCurrentWeatherData()
val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystemService)
currentWeatherData
.filterNotNull()
.collect { data ->
val value = data.firstOrNull()?.data?.let { w -> getValue(w) }
val value = getValue(data)
Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value")
if (value != null) {
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
} else {

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class CloudCoverDataType(context: Context) : BaseDataType(context, "cloudCover"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.cloudCover.toDouble()
class CloudCoverDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "cloudCover"){
override fun getValue(data: WeatherData): Double? {
return data.cloudCover
}
}

View File

@ -7,7 +7,7 @@ import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.saveWidgetSettings
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamCurrentForecastWeatherData
import de.timklge.karooheadwind.streamWidgetSettings
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@ -21,13 +21,13 @@ class CycleHoursAction : ActionCallback {
Log.d(KarooHeadwindExtension.TAG, "Cycling hours")
val currentSettings = context.streamWidgetSettings().first()
val data = context.streamCurrentWeatherData().firstOrNull()
val forecastData = context.streamCurrentForecastWeatherData().firstOrNull()
var hourOffset = currentSettings.currentForecastHourOffset + 3
val requestedPositions = data?.size
val requestedHours = data?.firstOrNull()?.data?.forecastData?.weatherCode?.size
val requestedPositions = forecastData?.data?.size
val requestedHours = forecastData?.data?.firstOrNull()?.forecasts?.size
if (data == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions > 1 && hourOffset >= requestedPositions)) {
if (forecastData == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions in 2..hourOffset)) {
hourOffset = 0
}

View File

@ -25,16 +25,16 @@ import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.HeadwindWidgetSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.OpenMeteoForecastData
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataResponse
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamCurrentForecastWeatherData
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile
@ -84,7 +84,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
}
data class StreamData(val data: List<WeatherDataResponse>?, val settings: SettingsAndProfile,
data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile,
val widgetSettings: HeadwindWidgetSettings? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null)
@ -97,57 +97,63 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
while (true) {
val data = (0..<10).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond
val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 }
val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() }
val forecastPrecipitationPropability = (0..<12).map { (0..100).random() }
val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() }
val forecastWeatherCodes =
(0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() }
val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() }
val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() }
val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() }
val weatherData = OpenMeteoCurrentWeatherResponse(
OpenMeteoData(
Instant.now().epochSecond,
0,
20.0,
50,
3.0,
0,
1013.25,
980.0,
15.0,
30.0,
30.0,
WeatherInterpretation.getKnownWeatherCodes().random()
),
0.0, 0.0, "Europe/Berlin", 30.0, 0,
OpenMeteoForecastData(
forecastTimes,
forecastTemperatures,
forecastPrecipitationPropability,
forecastPrecipitation,
forecastWeatherCodes,
forecastWindSpeed,
forecastWindDirection,
forecastWindGusts
)
val weatherData = (0..<12).map {
val forecastTime = timeAtFullHour + it * 60 * 60
val forecastTemperature = 20.0 + (-20..20).random()
val forecastPrecipitation = 0.0 + (0..10).random()
val forecastPrecipitationProbability = (0..100).random()
val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
val forecastWindSpeed = 0.0 + (0..10).random()
val forecastWindDirection = 0.0 + (0..360).random()
val forecastWindGusts = 0.0 + (0..10).random()
WeatherData(
time = forecastTime,
temperature = forecastTemperature,
relativeHumidity = 20.0,
precipitation = forecastPrecipitation,
cloudCover = 3.0,
sealevelPressure = 1013.25,
surfacePressure = 1013.25,
precipitationProbability = forecastPrecipitationProbability.toDouble(),
windSpeed = forecastWindSpeed,
windDirection = forecastWindDirection,
windGusts = forecastWindGusts,
weatherCode = forecastWeatherCode,
isForecast = true
)
}
val distancePerHour =
settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)
?.toDouble() ?: 0.0
val gpsCoords =
GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour)
WeatherDataResponse(weatherData, gpsCoords)
WeatherDataForLocation(
current = WeatherData(
time = timeAtFullHour,
temperature = 20.0,
relativeHumidity = 20.0,
precipitation = 0.0,
cloudCover = 3.0,
sealevelPressure = 1013.25,
surfacePressure = 1013.25,
windSpeed = 5.0,
windDirection = 180.0,
windGusts = 10.0,
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
isForecast = false
),
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
timezone = "UTC",
elevation = null,
forecasts = weatherData
)
}
emit(
StreamData(
data,
WeatherDataResponse(provider = WeatherDataProvider.OPEN_METEO, data = data),
SettingsAndProfile(
HeadwindSettings(),
settingsAndProfile?.isImperial == true,
@ -182,7 +188,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
previewFlow(settingsAndProfileStream)
} else {
combine(
context.streamCurrentWeatherData(),
context.streamCurrentForecastWeatherData(),
settingsAndProfileStream,
context.streamWidgetSettings(),
karooSystem.getHeadingFlow(context),
@ -204,7 +210,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
dataFlow.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, upcomingRoute) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData.isNullOrEmpty()){
if (allData?.data.isNullOrEmpty()){
emitter.updateView(
getErrorWidget(
glance,
@ -223,40 +229,27 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) {
val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0
val positionOffset = if (allData.size == 1) 0 else hourOffset
val positionOffset = if (allData?.data?.size == 1) 0 else hourOffset
var previousDate: String? = let {
val unixTime =
allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(
hourOffset
)
val unixTime = allData?.data?.getOrNull(positionOffset)?.forecasts?.getOrNull(hourOffset)?.time
val formattedDate = unixTime?.let {
getShortDateFormatter().format(
Instant.ofEpochSecond(unixTime)
)
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
}
formattedDate
}
for (baseIndex in hourOffset..hourOffset + 2) {
val positionIndex = if (allData.size == 1) 0 else baseIndex
val positionIndex = if (allData?.data?.size == 1) 0 else baseIndex
if (allData.getOrNull(positionIndex) == null) break
if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size
?: 0)
) {
break
}
if (allData?.data?.getOrNull(positionIndex) == null) break
if (baseIndex >= (allData.data.getOrNull(positionOffset)?.forecasts?.size ?: 0)) break
val data = allData.getOrNull(positionIndex)?.data
val distanceAlongRoute =
allData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute
val position =
allData.getOrNull(positionIndex)?.requestedPosition?.let {
"${
(it.distanceAlongRoute?.div(1000.0))?.toInt()
} at ${it.lat}, ${it.lon}"
val data = allData.data.getOrNull(positionIndex)
val distanceAlongRoute = allData.data.getOrNull(positionIndex)?.coords?.distanceAlongRoute
val position = allData.data.getOrNull(positionIndex)?.coords?.let {
"${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}"
}
if (baseIndex > hourOffset) {
@ -280,8 +273,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val isCurrent = baseIndex == 0 && positionIndex == 0
if (isCurrent && data?.current != null) {
val interpretation =
WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time
val formattedTime =
timeFormatter.format(Instant.ofEpochSecond(unixTime))
@ -307,32 +299,22 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
previousDate = formattedDate
} else {
val interpretation = WeatherInterpretation.fromWeatherCode(
data?.forecastData?.weatherCode?.get(baseIndex) ?: 0
)
val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 0
val formattedTime =
timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate =
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val weatherData = data?.forecasts?.getOrNull(baseIndex)
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0
RenderWidget(
arrowBitmap = baseBitmap,
current = interpretation,
windBearing = data?.forecastData?.windDirection?.get(baseIndex)
?.roundToInt() ?: 0,
windSpeed = data?.forecastData?.windSpeed?.get(baseIndex)
?.roundToInt() ?: 0,
windGusts = data?.forecastData?.windGusts?.get(baseIndex)
?.roundToInt() ?: 0,
precipitation = data?.forecastData?.precipitation?.get(baseIndex)
?: 0.0,
precipitationProbability = data?.forecastData?.precipitationProbability?.get(
baseIndex
) ?: 0,
temperature = data?.forecastData?.temperature?.get(baseIndex)
?.roundToInt() ?: 0,
windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0,
windGusts = weatherData?.windGusts?.roundToInt() ?: 0,
precipitation = weatherData?.precipitation ?: 0.0,
precipitationProbability = weatherData?.precipitationProbability?.toInt(),
temperature = weatherData?.temperature?.roundToInt() ?: 0,
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null,

View File

@ -16,17 +16,15 @@ import androidx.glance.layout.Column
import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.layout.wrapContentWidth
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue

View File

@ -42,8 +42,8 @@ class HeadwindDirectionDataType(
private fun streamValues(): Flow<Double> = flow {
karooSystem.getRelativeHeadingFlow(applicationContext)
.combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data ->
StreamData(headingResponse, data.firstOrNull()?.data?.current?.windDirection, data.firstOrNull()?.data?.current?.windSpeed)
.combine(applicationContext.streamCurrentWeatherData(karooSystem)) { headingResponse, data ->
StreamData(headingResponse, data?.windDirection, data?.windSpeed)
}
.combine(applicationContext.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) }
.collect { streamData ->

View File

@ -3,7 +3,7 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings
@ -16,7 +16,6 @@ import io.hammerhead.karooext.models.StreamState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlin.math.cos
@ -24,18 +23,17 @@ class HeadwindSpeedDataType(
private val karooSystem: KarooSystemService,
private val context: Context) : DataTypeImpl("karoo-headwind", "headwindSpeed"){
data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings)
data class StreamData(val headingResponse: HeadingResponse, val weatherData: WeatherData?, val settings: HeadwindSettings)
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamCurrentWeatherData(karooSystem)) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data.firstOrNull()?.data, settings)
StreamData(value, data, settings)
}
.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0
val windSpeed = streamData.weatherData?.windSpeed ?: 0.0
val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class PrecipitationDataType(context: Context) : BaseDataType(context, "precipitation"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.precipitation
class PrecipitationDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "precipitation"){
override fun getValue(data: WeatherData): Double {
return data.precipitation
}
}

View File

@ -21,7 +21,7 @@ import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.roundToInt

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class RelativeHumidityDataType(context: Context) : BaseDataType(context, "relativeHumidity"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.relativeHumidity.toDouble()
class RelativeHumidityDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "relativeHumidity"){
override fun getValue(data: WeatherData): Double? {
return data.relativeHumidity
}
}

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class SealevelPressureDataType(context: Context) : BaseDataType(context, "sealevelPressure"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.sealevelPressure ?: 0.0
class SealevelPressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "sealevelPressure"){
override fun getValue(data: WeatherData): Double? {
return data.sealevelPressure
}
}

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class SurfacePressureDataType(context: Context) : BaseDataType(context, "surfacePressure"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.surfacePressure
class SurfacePressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "surfacePressure"){
override fun getValue(data: WeatherData): Double? {
return data.surfacePressure
}
}

View File

@ -119,11 +119,11 @@ class TailwindAndRideSpeedDataType(
val flow = if (config.preview) {
previewFlow(karooSystem.streamUserProfile())
} else {
combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs ->
combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs ->
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
val absoluteWindDirection = weatherData.firstOrNull()?.data?.current?.windDirection
val windSpeed = weatherData.firstOrNull()?.data?.current?.windSpeed
val gustSpeed = weatherData.firstOrNull()?.data?.current?.windGusts
val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){
rideSpeedInMs * 2.23694
} else {
@ -141,7 +141,7 @@ class TailwindAndRideSpeedDataType(
Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view")
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
if (value == null || streamData.absoluteWindDirection == null || streamData.settings == null || streamData.windSpeed == null){
if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){
var headingResponse = streamData.headingResponse
if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.DpSize
import androidx.core.content.ContextCompat
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
@ -34,7 +33,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
@ -99,11 +97,11 @@ class TailwindDataType(
val flow = if (config.preview) {
previewFlow(karooSystem.streamUserProfile())
} else {
combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs ->
combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs ->
val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
val absoluteWindDirection = weatherData.firstOrNull()?.data?.current?.windDirection
val windSpeed = weatherData.firstOrNull()?.data?.current?.windSpeed
val gustSpeed = weatherData.firstOrNull()?.data?.current?.windGusts
val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){
rideSpeedInMs * 2.23694
} else {

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class TemperatureDataType(context: Context) : BaseDataType(context, "temperature"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.temperature
class TemperatureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "temperature"){
override fun getValue(data: WeatherData): Double {
return data.temperature
}
}

View File

@ -21,7 +21,7 @@ import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue

View File

@ -3,7 +3,7 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
@ -28,18 +28,18 @@ class UserWindSpeedDataType(
private val context: Context
) : DataTypeImpl("karoo-headwind", "userwindSpeed"){
data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings)
data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: WeatherData?, val settings: HeadwindSettings)
companion object {
fun streamValues(context: Context, karooSystem: KarooSystemService): Flow<Double> = flow {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamCurrentWeatherData(karooSystem)) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings ->
StreamData(value, data.firstOrNull()?.data, settings)
StreamData(value, data, settings)
}
.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0
val windSpeed = streamData.weatherResponse?.windSpeed ?: 0.0
val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0
if (streamData.settings.windDirectionIndicatorTextSetting == WindDirectionIndicatorTextSetting.HEADWIND_SPEED){

View File

@ -16,10 +16,9 @@ import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.MainActivity
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.OpenMeteoData
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings
@ -61,12 +60,12 @@ class WeatherDataType(
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
val currentWeatherData = applicationContext.streamCurrentWeatherData()
val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystem)
currentWeatherData
.collect { data ->
Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.firstOrNull()?.data?.current?.weatherCode}")
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data.firstOrNull()?.data?.current?.weatherCode?.toDouble() ?: 0.0)))))
Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.weatherCode}")
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.weatherCode?.toDouble() ?: 0.0)))))
}
}
emitter.setCancellable {
@ -74,19 +73,15 @@ class WeatherDataType(
}
}
data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings,
data class StreamData(val data: WeatherData?, val settings: HeadwindSettings,
val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null)
private fun previewFlow(): Flow<StreamData> = flow {
while (true){
emit(StreamData(
OpenMeteoCurrentWeatherResponse(
OpenMeteoData(Instant.now().epochSecond, 0, 20.0, 50, 3.0, 0, 1013.25, 980.0, 15.0, 30.0, 30.0, WeatherInterpretation.getKnownWeatherCodes().random()),
0.0, 0.0, "Europe/Berlin", 30.0, 0,
null
), HeadwindSettings()
))
WeatherData(Instant.now().epochSecond, 0.0,
20.0, 50.0, 3.0, 0.0, 1013.25, 980.0, 15.0, 30.0, 30.0,
WeatherInterpretation.getKnownWeatherCodes().random(), isForecast = false), HeadwindSettings()))
delay(5_000)
}
@ -107,8 +102,8 @@ class WeatherDataType(
val dataFlow = if (config.preview){
previewFlow()
} else {
combine(context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), karooSystem.getHeadingFlow(context)) { data, settings, profile, heading ->
StreamData(data.firstOrNull()?.data, settings, profile, heading)
combine(context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), karooSystem.getHeadingFlow(context)) { data, settings, profile, heading ->
StreamData(data, settings, profile, heading)
}
}
@ -124,9 +119,9 @@ class WeatherDataType(
return@collect
}
val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.current.time))
val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(data.current.time))
val interpretation = WeatherInterpretation.fromWeatherCode(data.weatherCode)
val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.time))
val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(data.time))
val result = glance.compose(context, DpSize.Unspecified) {
var modifier = GlanceModifier.fillMaxSize()
@ -136,12 +131,12 @@ class WeatherDataType(
Weather(
baseBitmap,
current = interpretation,
windBearing = data.current.windDirection.roundToInt(),
windSpeed = data.current.windSpeed.roundToInt(),
windGusts = data.current.windGusts.roundToInt(),
precipitation = data.current.precipitation,
windBearing = data.windDirection.roundToInt(),
windSpeed = data.windSpeed.roundToInt(),
windGusts = data.windGusts.roundToInt(),
precipitation = data.precipitation,
precipitationProbability = null,
temperature = data.current.temperature.roundToInt(),
temperature = data.temperature.roundToInt(),
temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
rowAlignment = when (config.alignment){

View File

@ -3,7 +3,7 @@ package de.timklge.karooheadwind.datatypes
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
class WeatherForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "weatherForecast") {

View File

@ -31,7 +31,7 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale

View File

@ -17,7 +17,7 @@ import androidx.glance.text.FontFamily
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.streamDataFlow
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.internal.ViewEmitter
@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class WindDirectionDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(context, "windDirection"){
class WindDirectionDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(karooSystem, context, "windDirection"){
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
@ -48,8 +48,8 @@ class WindDirectionDataType(val karooSystem: KarooSystemService, context: Contex
)
}
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.windDirection
override fun getValue(data: WeatherData): Double {
return data.windDirection
}
private fun previewFlow(): Flow<Double> {

View File

@ -17,7 +17,6 @@ import androidx.glance.layout.ContentScale
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.text.FontFamily
@ -26,10 +25,9 @@ import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable
fun WindForecast(

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class WindGustsDataType(context: Context) : BaseDataType(context, "windGusts"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.windGusts
class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){
override fun getValue(data: WeatherData): Double {
return data.windGusts
}
}

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
class WindSpeedDataType(context: Context) : BaseDataType(context, "windSpeed"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double {
return data.current.windSpeed
class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){
override fun getValue(data: WeatherData): Double {
return data.windSpeed
}
}

View File

@ -1,7 +1,6 @@
package de.timklge.karooheadwind.screens
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -25,15 +24,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.timklge.karooheadwind.HeadwindStats
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.ServiceStatusSingleton
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.ForecastDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter
import de.timklge.karooheadwind.datatypes.getShortDateFormatter
import de.timklge.karooheadwind.getGpsCoordinateFlow
import de.timklge.karooheadwind.streamCurrentForecastWeatherData
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamStats
import de.timklge.karooheadwind.streamUpcomingRoute
@ -52,10 +51,26 @@ fun WeatherScreen(onFinish: () -> Unit) {
val ctx = LocalContext.current
val karooSystem = remember { KarooSystemService(ctx) }
val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null)
val stats by ctx.streamStats().collectAsStateWithLifecycle(HeadwindStats())
val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null)
val weatherData by ctx.streamCurrentWeatherData().collectAsStateWithLifecycle(emptyList())
val profileFlow = remember { karooSystem.streamUserProfile() }
val profile by profileFlow.collectAsStateWithLifecycle(null)
val statsFlow = remember { ctx.streamStats() }
val stats by statsFlow.collectAsStateWithLifecycle(HeadwindStats())
val locationFlow = remember { karooSystem.getGpsCoordinateFlow(ctx) }
val location by locationFlow.collectAsStateWithLifecycle(null)
val currentWeatherDataFlow = remember { ctx.streamCurrentWeatherData(karooSystem) }
val currentWeatherData by currentWeatherDataFlow.collectAsStateWithLifecycle(null)
val forecastDataFlow = remember { ctx.streamCurrentForecastWeatherData() }
val forecastData by forecastDataFlow.collectAsStateWithLifecycle(null)
val upcomingRouteFlow = remember { karooSystem.streamUpcomingRoute() }
val upcomingRoute by upcomingRouteFlow.collectAsStateWithLifecycle(null)
val serviceStatusFlow = remember { ServiceStatusSingleton.getInstance().getServiceStatus() }
val serviceStatus by serviceStatusFlow.collectAsStateWithLifecycle(false)
val baseBitmap = BitmapFactory.decodeResource(
ctx.resources,
@ -85,21 +100,20 @@ fun WeatherScreen(onFinish: () -> Unit) {
)
}
val currentWeatherData = weatherData.firstOrNull()?.data
val requestedWeatherPosition = weatherData.firstOrNull()?.requestedPosition
val requestedWeatherPosition = forecastData?.data?.firstOrNull()?.coords
val formattedTime = currentWeatherData?.let { timeFormatter.format(Instant.ofEpochSecond(currentWeatherData.current.time)) }
val formattedDate = currentWeatherData?.let { getShortDateFormatter().format(Instant.ofEpochSecond(currentWeatherData.current.time)) }
val formattedTime = currentWeatherData?.let { timeFormatter.format(Instant.ofEpochSecond(it.time)) }
val formattedDate = currentWeatherData?.let { getShortDateFormatter().format(Instant.ofEpochSecond(it.time)) }
if (karooConnected == true && currentWeatherData != null) {
WeatherWidget(
baseBitmap = baseBitmap,
current = WeatherInterpretation.fromWeatherCode(currentWeatherData.current.weatherCode),
windBearing = currentWeatherData.current.windDirection.roundToInt(),
windSpeed = currentWeatherData.current.windSpeed.roundToInt(),
windGusts = currentWeatherData.current.windGusts.roundToInt(),
precipitation = currentWeatherData.current.precipitation,
temperature = currentWeatherData.current.temperature.toInt(),
current = WeatherInterpretation.fromWeatherCode(currentWeatherData?.weatherCode),
windBearing = currentWeatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = currentWeatherData?.windSpeed?.roundToInt() ?: 0,
windGusts = currentWeatherData?.windGusts?.roundToInt() ?: 0,
precipitation = currentWeatherData?.precipitation ?: 0.0,
temperature = currentWeatherData?.temperature?.toInt() ?: 0,
temperatureUnit = if(profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedTime,
dateLabel = formattedDate,
@ -113,8 +127,6 @@ fun WeatherScreen(onFinish: () -> Unit) {
val lastPositionDistanceStr =
lastPosition?.let { dist -> " (${dist.roundToInt()} km away)" } ?: ""
val serviceStatus by ServiceStatusSingleton.getInstance().getServiceStatus().collectAsStateWithLifecycle(false)
if (!serviceStatus){
Text(
modifier = Modifier.padding(5.dp),
@ -178,21 +190,19 @@ fun WeatherScreen(onFinish: () -> Unit) {
)
}
val upcomingRoute by karooSystem.streamUpcomingRoute().collectAsStateWithLifecycle(null)
for (index in 1..12){
val positionIndex = if (weatherData.size == 1) 0 else index
val positionIndex = if (forecastData?.data?.size == 1) 0 else index
if (weatherData.getOrNull(positionIndex) == null) break
if (index >= (weatherData.getOrNull(positionIndex)?.data?.forecastData?.weatherCode?.size ?: 0)) {
if (forecastData?.data?.getOrNull(positionIndex) == null) break
if (index >= (forecastData?.data?.getOrNull(positionIndex)?.forecasts?.size ?: 0)) {
break
}
val data = weatherData.getOrNull(positionIndex)?.data
val distanceAlongRoute = weatherData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute
val position = weatherData.getOrNull(positionIndex)?.requestedPosition?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" }
val data = forecastData?.data?.getOrNull(positionIndex)
val distanceAlongRoute = forecastData?.data?.getOrNull(positionIndex)?.coords?.distanceAlongRoute
val position = forecastData?.data?.getOrNull(positionIndex)?.coords?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" }
Log.d(KarooHeadwindExtension.TAG, "Distance along route index ${positionIndex}: $position")
// Log.d(KarooHeadwindExtension.TAG, "Distance along route index ${positionIndex}: $position")
if (index > 1) {
Spacer(
@ -209,25 +219,26 @@ fun WeatherScreen(onFinish: () -> Unit) {
distanceAlongRoute?.minus(currentDistanceAlongRoute)
}
val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(index) ?: 0)
val unixTime = data?.forecastData?.time?.get(index) ?: 0
val weatherData = data?.forecasts?.getOrNull(index)
val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
val unixTime = weatherData?.time ?: 0
val formattedForecastTime = ForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
WeatherWidget(
baseBitmap,
current = interpretation,
windBearing = data?.forecastData?.windDirection?.get(index)?.roundToInt() ?: 0,
windSpeed = data?.forecastData?.windSpeed?.get(index)?.roundToInt() ?: 0,
windGusts = data?.forecastData?.windGusts?.get(index)?.roundToInt() ?: 0,
precipitation = data?.forecastData?.precipitation?.get(index) ?: 0.0,
temperature = data?.forecastData?.temperature?.get(index)?.roundToInt() ?: 0,
windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0,
windGusts = weatherData?.windGusts?.roundToInt() ?: 0,
precipitation = weatherData?.precipitation ?: 0.0,
temperature = weatherData?.temperature?.toInt() ?: 0,
temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT,
timeLabel = formattedForecastTime,
dateLabel = formattedForecastDate,
distance = distanceFromCurrent,
includeDistanceLabel = true,
precipitationProbability = data?.forecastData?.precipitationProbability?.get(index) ?: 0,
precipitationProbability = weatherData?.precipitationProbability?.toInt() ?: 0,
isImperial = profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
)
}

View File

@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation
import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import de.timklge.karooheadwind.datatypes.getArrowBitmapByBearing
import de.timklge.karooheadwind.datatypes.getWeatherIcon
import kotlin.math.absoluteValue
@ -65,7 +65,7 @@ fun WeatherWidget(
)
}
if (distance != null) {
if (distance != null && distance > 200) {
val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt()
val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}"
val text = if (includeDistanceLabel){
@ -154,7 +154,7 @@ fun WeatherWidget(
modifier = Modifier.padding(top = 4.dp)
) {
Image(
bitmap = getArrowBitmapByBearing(baseBitmap, windBearing).asImageBitmap(),
bitmap = getArrowBitmapByBearing(baseBitmap, windBearing + 180).asImageBitmap(),
colorFilter = ColorFilter.tint(Color.Black),
contentDescription = "Wind direction",
modifier = Modifier.size(20.dp)

View File

@ -1,4 +1,4 @@
package de.timklge.karooheadwind
package de.timklge.karooheadwind.util
import kotlin.math.abs

View File

@ -0,0 +1,19 @@
package de.timklge.karooheadwind.util
import io.hammerhead.karooext.models.HttpResponseState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.zip.GZIPInputStream
suspend fun ungzip(response: HttpResponseState.Complete): String {
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
return if(isGzippedResponse){
val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) }
gzipStream.use { stream -> String(stream.readBytes()) }
} else {
inputStream.use { stream -> String(stream.readBytes()) }
}
}

View File

@ -0,0 +1,21 @@
package de.timklge.karooheadwind.weatherprovider
import kotlinx.serialization.Serializable
@Serializable
data class WeatherData(
val time: Long,
val temperature: Double,
val relativeHumidity: Double? = null,
val precipitation: Double,
val precipitationProbability: Double? = null,
val cloudCover: Double? = null,
val sealevelPressure: Double? = null,
val surfacePressure: Double? = null,
val windSpeed: Double,
val windDirection: Double,
val windGusts: Double,
val weatherCode: Int,
val isForecast: Boolean
)

View File

@ -0,0 +1,13 @@
package de.timklge.karooheadwind.weatherprovider
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import kotlinx.serialization.Serializable
@Serializable
data class WeatherDataForLocation(
val current: WeatherData,
val coords: GpsCoordinates,
val timezone: String? = null,
val elevation: Double? = null,
val forecasts: List<WeatherData>? = null,
)

View File

@ -0,0 +1,11 @@
package de.timklge.karooheadwind.weatherprovider
import de.timklge.karooheadwind.WeatherDataProvider
import kotlinx.serialization.Serializable
@Serializable
data class WeatherDataResponse(
val error: String? = null,
val provider: WeatherDataProvider,
val data: List<WeatherDataForLocation>,
)

View File

@ -0,0 +1,22 @@
package de.timklge.karooheadwind.weatherprovider
enum class WeatherInterpretation {
CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN;
companion object {
// WMO weather interpretation codes (WW)
fun fromWeatherCode(code: Int?): WeatherInterpretation {
return when(code){
0 -> CLEAR
1, 2, 3 -> CLOUDY
45, 48, 61, 63, 65, 66, 67, 80, 81, 82 -> RAINY
71, 73, 75, 77, 85, 86 -> SNOWY
51, 53, 55, 56, 57 -> DRIZZLE
95, 96, 99 -> THUNDERSTORM
else -> UNKNOWN
}
}
fun getKnownWeatherCodes(): Set<Int> = setOf(0, 1, 2, 3, 45, 48, 61, 63, 65, 66, 67, 80, 81, 82, 71, 73, 75, 77, 85, 86, 51, 53, 55, 56, 57, 95, 96, 99)
}
}

View File

@ -1,8 +1,8 @@
package de.timklge.karooheadwind
package de.timklge.karooheadwind.weatherprovider
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.UserProfile
interface WeatherProvider {
@ -11,5 +11,5 @@ interface WeatherProvider {
coordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): HttpResponseState.Complete
): WeatherDataResponse
}

View File

@ -0,0 +1,3 @@
package de.timklge.karooheadwind.weatherprovider
class WeatherProviderException(val statusCode: Int, message: String) : Exception(message)

View File

@ -0,0 +1,82 @@
package de.timklge.karooheadwind.weatherprovider
import android.util.Log
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.weatherprovider.openmeteo.OpenMeteoWeatherProvider
import de.timklge.karooheadwind.weatherprovider.openweathermap.OpenWeatherMapWeatherProvider
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
import java.time.LocalDate
object WeatherProviderFactory {
private var openWeatherMapConsecutiveFailures = 0
private var openWeatherMapTotalFailures = 0
private var openMeteoSuccessfulAfterFailures = false
private var fallbackUntilDate: LocalDate? = null
private const val MAX_FAILURES_BEFORE_TEMP_FALLBACK = 3
private const val MAX_FAILURES_BEFORE_DAILY_FALLBACK = 20
suspend fun makeWeatherRequest(
karooSystemService: KarooSystemService,
gpsCoordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): WeatherDataResponse {
val provider = getProvider(settings)
try {
val response = provider.getWeatherData(karooSystemService, gpsCoordinates, settings, profile)
return response
} catch(e: Throwable){
Log.d(KarooHeadwindExtension.TAG, "Weather request failed: $e")
if (provider is OpenWeatherMapWeatherProvider && (e is WeatherProviderException && (e.statusCode == 401 || e.statusCode == 403))) {
handleOpenWeatherMapFailure()
}
throw e;
}
}
private fun getProvider(settings: HeadwindSettings): WeatherProvider {
val currentDate = LocalDate.now()
if (fallbackUntilDate != null && !currentDate.isAfter(fallbackUntilDate)) {
Log.d(KarooHeadwindExtension.TAG, "Using fallback OpenMeteo until $fallbackUntilDate")
return OpenMeteoWeatherProvider()
}
if (settings.weatherProvider == WeatherDataProvider.OPEN_WEATHER_MAP &&
openWeatherMapConsecutiveFailures >= MAX_FAILURES_BEFORE_TEMP_FALLBACK
) {
openWeatherMapConsecutiveFailures = 0
Log.d(KarooHeadwindExtension.TAG, "Using temporary fallback OpenMeteo")
return OpenMeteoWeatherProvider()
}
return when (settings.weatherProvider) {
WeatherDataProvider.OPEN_METEO -> OpenMeteoWeatherProvider()
WeatherDataProvider.OPEN_WEATHER_MAP -> OpenWeatherMapWeatherProvider(settings.openWeatherMapApiKey)
}
}
private fun handleOpenWeatherMapFailure() {
openWeatherMapConsecutiveFailures++
openWeatherMapTotalFailures++
Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap failed $openWeatherMapConsecutiveFailures times consecutive, $openWeatherMapTotalFailures total times")
if (openWeatherMapTotalFailures >= MAX_FAILURES_BEFORE_DAILY_FALLBACK && openMeteoSuccessfulAfterFailures) {
fallbackUntilDate = LocalDate.now()
Log.d(KarooHeadwindExtension.TAG, "Activated daily fallback OpenMeteo until $fallbackUntilDate")
}
}
}

View File

@ -0,0 +1,36 @@
package de.timklge.karooheadwind.weatherprovider.openmeteo
import de.timklge.karooheadwind.weatherprovider.WeatherData
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpenMeteoWeatherData(
val time: Long, val interval: Int,
@SerialName("temperature_2m") val temperature: Double,
@SerialName("relative_humidity_2m") val relativeHumidity: Int,
@SerialName("precipitation") val precipitation: Double,
@SerialName("cloud_cover") val cloudCover: Int,
@SerialName("surface_pressure") val surfacePressure: Double,
@SerialName("pressure_msl") val sealevelPressure: Double? = null,
@SerialName("wind_speed_10m") val windSpeed: Double,
@SerialName("wind_direction_10m") val windDirection: Double,
@SerialName("wind_gusts_10m") val windGusts: Double,
@SerialName("weather_code") val weatherCode: Int,
) {
fun toWeatherData(): WeatherData = WeatherData(
temperature = temperature,
relativeHumidity = relativeHumidity.toDouble(),
precipitation = precipitation,
cloudCover = cloudCover.toDouble(),
surfacePressure = surfacePressure,
sealevelPressure = sealevelPressure,
windSpeed = windSpeed,
windDirection = windDirection,
windGusts = windGusts,
weatherCode = weatherCode,
time = time,
isForecast = false,
)
}

View File

@ -0,0 +1,28 @@
package de.timklge.karooheadwind.weatherprovider.openmeteo
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpenMeteoWeatherDataForLocation(
val current: OpenMeteoWeatherData,
val latitude: Double,
val longitude: Double,
val timezone: String,
val elevation: Double,
@SerialName("utc_offset_seconds") val utfOffsetSeconds: Int,
@SerialName("hourly") val forecastData: OpenMeteoWeatherForecastData?,
) {
fun toWeatherDataForLocation(distanceAlongRoute: Double?): WeatherDataForLocation {
val forecasts = forecastData?.toWeatherData()
return WeatherDataForLocation(
current = current.toWeatherData(),
coords = GpsCoordinates(latitude, longitude, bearing = null, distanceAlongRoute = distanceAlongRoute),
timezone = timezone,
elevation = elevation,
forecasts = forecasts
)
}
}

View File

@ -0,0 +1,32 @@
package de.timklge.karooheadwind.weatherprovider.openmeteo
import de.timklge.karooheadwind.weatherprovider.WeatherData
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpenMeteoWeatherForecastData(
@SerialName("time") val time: List<Long>,
@SerialName("temperature_2m") val temperature: List<Double>,
@SerialName("precipitation_probability") val precipitationProbability: List<Int>,
@SerialName("precipitation") val precipitation: List<Double>,
@SerialName("weather_code") val weatherCode: List<Int>,
@SerialName("wind_speed_10m") val windSpeed: List<Double>,
@SerialName("wind_direction_10m") val windDirection: List<Double>,
@SerialName("wind_gusts_10m") val windGusts: List<Double>,
) {
fun toWeatherData(): List<WeatherData> {
return time.mapIndexed { index, t ->
WeatherData(
temperature = temperature[index],
precipitation = precipitation[index],
windSpeed = windSpeed[index],
windDirection = windDirection[index],
windGusts = windGusts[index],
weatherCode = weatherCode[index],
time = t,
isForecast = true,
)
}
}
}

View File

@ -0,0 +1,103 @@
package de.timklge.karooheadwind.weatherprovider.openmeteo
import android.util.Log
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.PrecipitationUnit
import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.jsonWithUnknownKeys
import de.timklge.karooheadwind.util.ungzip
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherProvider
import de.timklge.karooheadwind.weatherprovider.WeatherProviderException
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.OnHttpResponse
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
class OpenMeteoWeatherProvider : WeatherProvider {
@OptIn(FlowPreview::class)
private suspend fun makeOpenMeteoWeatherRequest(karooSystemService: KarooSystemService, gpsCoordinates: List<GpsCoordinates>, settings: HeadwindSettings, profile: UserProfile?): String {
val precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH
val temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT
val response = callbackFlow {
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12
val lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) }
val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=surface_pressure,pressure_msl,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}"
Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")
val listenerId = karooSystemService.addConsumer(
OnHttpResponse.MakeHttpRequest(
"GET",
url,
waitForConnection = false,
headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG, "Accept-Encoding" to "gzip"),
),
onEvent = { event: OnHttpResponse ->
if (event.state is HttpResponseState.Complete){
Log.d(KarooHeadwindExtension.TAG, "Http response received")
trySend(event.state as HttpResponseState.Complete)
close()
}
},
onError = { err ->
Log.d(KarooHeadwindExtension.TAG, "Http error: $err")
close(WeatherProviderException(0, "Http error: $err"))
})
awaitClose {
karooSystemService.removeConsumer(listenerId)
}
}.timeout(30.seconds).catch { e: Throwable ->
if (e is TimeoutCancellationException){
emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout"))
} else {
throw e
}
}.single()
if (response.statusCode !in 200..299) {
Log.e(KarooHeadwindExtension.TAG, "OpenMeteo API request failed with status code ${response.statusCode}")
throw WeatherProviderException(response.statusCode, "OpenMeteo API request failed with status code ${response.statusCode}")
}
return ungzip(response)
}
override suspend fun getWeatherData(
karooSystem: KarooSystemService,
coordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): WeatherDataResponse {
val openMeteoResponse = makeOpenMeteoWeatherRequest(karooSystem, coordinates, settings, profile)
val weatherData = if (coordinates.size == 1) {
listOf(jsonWithUnknownKeys.decodeFromString<OpenMeteoWeatherDataForLocation>(openMeteoResponse))
} else {
jsonWithUnknownKeys.decodeFromString<List<OpenMeteoWeatherDataForLocation>>(openMeteoResponse)
}
val response = WeatherDataResponse(
provider = WeatherDataProvider.OPEN_METEO,
data = weatherData.zip(coordinates) { openMeteoWeatherDataForLocation, location ->
openMeteoWeatherDataForLocation.toWeatherDataForLocation(location.distanceAlongRoute)
}
)
return response
}
}

View File

@ -0,0 +1,39 @@
package de.timklge.karooheadwind.weatherprovider.openweathermap
import de.timklge.karooheadwind.weatherprovider.WeatherData
import kotlinx.serialization.Serializable
@Serializable
data class OpenWeatherMapForecastData(
val dt: Long,
val temp: Double,
val feels_like: Double,
val pressure: Int,
val humidity: Int,
val clouds: Int,
val visibility: Int,
val wind_speed: Double,
val wind_deg: Int,
val wind_gust: Double? = null,
val pop: Double,
val rain: Rain? = null,
val snow: Snow? = null,
val weather: List<Weather>
) {
fun toWeatherData(): WeatherData = WeatherData(
temperature = temp,
relativeHumidity = humidity.toDouble(),
precipitation = rain?.h1 ?: 0.0,
cloudCover = clouds.toDouble(),
surfacePressure = pressure.toDouble(),
sealevelPressure = pressure.toDouble(), // FIXME
windSpeed = wind_speed,
windDirection = wind_deg.toDouble(),
windGusts = wind_gust ?: wind_speed,
weatherCode = OpenWeatherMapWeatherProvider.convertWeatherCodeToOpenMeteo(
weather.firstOrNull()?.id ?: 800
),
time = dt,
isForecast = true
)
}

View File

@ -0,0 +1,40 @@
package de.timklge.karooheadwind.weatherprovider.openweathermap
import de.timklge.karooheadwind.weatherprovider.WeatherData
import kotlinx.serialization.Serializable
@Serializable
data class OpenWeatherMapWeatherData(
val dt: Long,
val sunrise: Long,
val sunset: Long,
val temp: Double,
val feels_like: Double,
val pressure: Int,
val humidity: Int,
val clouds: Int,
val visibility: Int,
val wind_speed: Double,
val wind_deg: Int,
val wind_gust: Double? = null,
val rain: Rain? = null,
val snow: Snow? = null,
val weather: List<Weather>){
fun toWeatherData(): WeatherData = WeatherData(
temperature = temp,
relativeHumidity = humidity.toDouble(),
precipitation = rain?.h1 ?: 0.0,
cloudCover = clouds.toDouble(),
surfacePressure = pressure.toDouble(),
sealevelPressure = pressure.toDouble(), // FIXME
windSpeed = wind_speed,
windDirection = wind_deg.toDouble(),
windGusts = wind_gust ?: wind_speed,
weatherCode = OpenWeatherMapWeatherProvider.convertWeatherCodeToOpenMeteo(
weather.firstOrNull()?.id ?: 800
),
time = dt,
isForecast = false
)
}

View File

@ -0,0 +1,23 @@
package de.timklge.karooheadwind.weatherprovider.openweathermap
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class OpenWeatherMapWeatherDataForLocation(
val lat: Double,
val lon: Double,
val timezone: String,
@SerialName("timezone_offset") val timezoneOffset: Int,
val current: OpenWeatherMapWeatherData,
val hourly: List<OpenWeatherMapForecastData>
){
fun toWeatherDataForLocation(distanceAlongRoute: Double?): WeatherDataForLocation = WeatherDataForLocation(
current = current.toWeatherData(),
coords = GpsCoordinates(lat, lon, bearing = null, distanceAlongRoute = distanceAlongRoute),
timezone = timezone,
forecasts = hourly.map { it.toWeatherData() }
)
}

View File

@ -0,0 +1,143 @@
package de.timklge.karooheadwind.weatherprovider.openweathermap
import android.util.Log
import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.jsonWithUnknownKeys
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherProvider
import de.timklge.karooheadwind.weatherprovider.WeatherProviderException
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.OnHttpResponse
import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Duration.Companion.seconds
@Serializable
data class Weather(
val id: Int,
val main: String,
val description: String,
val icon: String
)
@Serializable
data class Rain(
@SerialName("1h") val h1: Double = 0.0,
@SerialName("3h") val h3: Double = 0.0
)
@Serializable
data class Snow(
@SerialName("1h") val h1: Double = 0.0,
@SerialName("3h") val h3: Double = 0.0
)
class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvider {
companion object {
fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int {
// Mapping OpenWeatherMap to WMO OpenMeteo
return when (owmCode) {
in 200..299 -> 95 // Thunderstorm
in 300..399 -> 51 // Drizzle
in 500..599 -> 61 // Rain
in 600..699 -> 71 // Snow
800 -> 0 // Clear
in 801..804 -> 1 // Cloudy
else -> 0
}
}
}
override suspend fun getWeatherData(
karooSystem: KarooSystemService,
coordinates: List<GpsCoordinates>,
settings: HeadwindSettings,
profile: UserProfile?
): WeatherDataResponse {
val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey)
val responseBody = response.body?.let { String(it) } ?: throw Exception("Null response from OpenWeatherMap")
val responses = mutableListOf<WeatherDataForLocation>()
val openWeatherMapWeatherDataForLocation = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody)
responses.add(openWeatherMapWeatherDataForLocation.toWeatherDataForLocation(null))
// FIXME Route forecast
return WeatherDataResponse(
provider = WeatherDataProvider.OPEN_WEATHER_MAP,
data = responses
)
}
@OptIn(FlowPreview::class)
private suspend fun makeOpenWeatherMapRequest(
service: KarooSystemService,
coordinates: List<GpsCoordinates>,
apiKey: String
): HttpResponseState.Complete {
val response = callbackFlow {
val coordinate = coordinates.first()
// URL API 3.0 with onecall endpoint
val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" +
"&appid=$apiKey&units=metric&exclude=minutely,daily,alerts"
Log.d(KarooHeadwindExtension.TAG, "Http request to OpenWeatherMap API 3.0: $url")
val listenerId = service.addConsumer(
OnHttpResponse.MakeHttpRequest(
"GET",
url,
waitForConnection = false,
headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG)
),
onEvent = { event: OnHttpResponse ->
if (event.state is HttpResponseState.Complete) {
Log.d(KarooHeadwindExtension.TAG, "Http response received from OpenWeatherMap")
trySend(event.state as HttpResponseState.Complete)
close()
}
},
onError = { err ->
Log.e(KarooHeadwindExtension.TAG, "Http error: $err")
close(WeatherProviderException(0, err))
}
)
awaitClose {
service.removeConsumer(listenerId)
}
}.timeout(30.seconds).catch { e: Throwable ->
if (e is TimeoutCancellationException) {
emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout"))
} else {
throw e
}
}.single()
if (response.statusCode == 401 || response.statusCode == 403){
Log.e(KarooHeadwindExtension.TAG, "OpenWeatherMap API key is invalid or expired")
throw WeatherProviderException(response.statusCode, "OpenWeatherMap API key is invalid or expired")
} else if (response.statusCode !in 200..299) {
Log.e(KarooHeadwindExtension.TAG, "OpenWeatherMap API request failed with status code ${response.statusCode}")
throw WeatherProviderException(response.statusCode, "OpenWeatherMap API request failed with status code ${response.statusCode}")
}
return response
}
}

View File

@ -8,9 +8,9 @@
<color name="green">#00ff00</color>
<color name="orange">#ff9930</color>
<color name="red">#FF2424</color>
<color name="red">#FF5454</color>
<color name="hGreen">#008000</color>
<color name="hOrange">#BB4300</color>
<color name="hRed">#B30000</color>
<color name="hRed">#A30000</color>
</resources>