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.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import com.mapbox.geojson.LineString import com.mapbox.geojson.LineString
import com.mapbox.geojson.Point
import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.GpsCoordinates 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.KarooSystemService
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.OnNavigationState import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.minutes
val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true }
val settingsKey = stringPreferencesKey("settings") val settingsKey = stringPreferencesKey("settings")
val widgetSettingsKey = stringPreferencesKey("widgetSettings") val widgetSettingsKey = stringPreferencesKey("widgetSettings")
val currentDataKey = stringPreferencesKey("currentForecasts") val currentDataKey = stringPreferencesKey("currentForecastsUnified")
val statsKey = stringPreferencesKey("stats") val statsKey = stringPreferencesKey("stats")
val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition") val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition")
@ -53,12 +61,9 @@ suspend fun saveStats(context: Context, stats: HeadwindStats) {
} }
} }
@Serializable suspend fun saveCurrentData(context: Context, response: WeatherDataResponse) {
data class WeatherDataResponse(val data: OpenMeteoCurrentWeatherResponse, val requestedPosition: GpsCoordinates)
suspend fun saveCurrentData(context: Context, forecast: List<WeatherDataResponse>) {
context.dataStore.edit { t -> 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) data class UpcomingRoute(val distanceAlongRoute: Double, val routePolyline: LineString, val routeLength: Double)
fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> { fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> {
val distanceToDestinationStream = flow { val distanceToDestinationStream = streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION)
emit(null) .map { (it as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION) }
.distinctUntilChanged()
streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION)
.map { (it as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION) }
.collect { emit(it) }
}
var lastKnownDistanceAlongRoute = 0.0 var lastKnownDistanceAlongRoute = 0.0
var lastKnownRoutePolyline: LineString? = null var lastKnownRoutePolyline: LineString? = null
@ -130,6 +131,7 @@ fun KarooSystemService.streamUpcomingRoute(): Flow<UpcomingRoute?> {
.map { navigationState -> .map { navigationState ->
navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) } navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) }
} }
.distinctUntilChanged()
.combine(distanceToDestinationStream) { routePolyline, distanceToDestination -> .combine(distanceToDestinationStream) { routePolyline, distanceToDestination ->
Log.d(KarooHeadwindExtension.TAG, "Route polyline size: ${routePolyline?.coordinates()?.size}, distance to destination: $distanceToDestination") Log.d(KarooHeadwindExtension.TAG, "Route polyline size: ${routePolyline?.coordinates()?.size}, distance to destination: $distanceToDestination")
if (routePolyline != null){ 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 -> return dataStore.data.map { settingsJson ->
try { try {
val data = settingsJson[currentDataKey] val data = settingsJson[currentDataKey]
data?.let { d -> jsonWithUnknownKeys.decodeFromString<List<WeatherDataResponse>>(d) } ?: emptyList()
data?.let { d -> jsonWithUnknownKeys.decodeFromString<WeatherDataResponse>(d) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e)
emptyList() null
} }
}.distinctUntilChanged().map { response -> }.distinctUntilChanged()
response.filter { forecast -> }
forecast.data.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)
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)
}
} }
} }
} }

View File

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

View File

@ -1,7 +1,6 @@
package de.timklge.karooheadwind package de.timklge.karooheadwind
import android.util.Log import android.util.Log
import androidx.compose.ui.util.fastZip
import com.mapbox.geojson.LineString import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement 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.WindForecastDataType
import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType
import de.timklge.karooheadwind.weatherprovider.WeatherProviderFactory
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.KarooExtension import io.hammerhead.karooext.extension.KarooExtension
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
@ -45,11 +45,9 @@ import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.time.debounce import kotlinx.coroutines.time.debounce
import kotlinx.coroutines.withContext
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.zip.GZIPInputStream
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
@ -73,15 +71,15 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
WeatherDataType(karooSystem, applicationContext), WeatherDataType(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem), WeatherForecastDataType(karooSystem),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(applicationContext), RelativeHumidityDataType(karooSystem, applicationContext),
CloudCoverDataType(applicationContext), CloudCoverDataType(karooSystem, applicationContext),
WindGustsDataType(applicationContext), WindGustsDataType(karooSystem, applicationContext),
WindSpeedDataType(applicationContext), WindSpeedDataType(karooSystem, applicationContext),
TemperatureDataType(applicationContext), TemperatureDataType(karooSystem, applicationContext),
WindDirectionDataType(karooSystem, applicationContext), WindDirectionDataType(karooSystem, applicationContext),
PrecipitationDataType(applicationContext), PrecipitationDataType(karooSystem, applicationContext),
SurfacePressureDataType(applicationContext), SurfacePressureDataType(karooSystem, applicationContext),
SealevelPressureDataType(applicationContext), SealevelPressureDataType(karooSystem, applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext), UserWindSpeedDataType(karooSystem, applicationContext),
TemperatureForecastDataType(karooSystem), TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem), PrecipitationForecastDataType(karooSystem),
@ -121,10 +119,9 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
} }
.debounce(Duration.ofSeconds(5)) .debounce(Duration.ofSeconds(5))
var requestedGpsCoordinates: List<GpsCoordinates> = mutableListOf() var requestedGpsCoordinates: List<GpsCoordinates> = emptyList()
val settingsStream = streamSettings(karooSystem) val settingsStream = streamSettings(karooSystem).filter { it.welcomeDialogAccepted }
.filter { it.welcomeDialogAccepted }
data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile?, val upcomingRoute: UpcomingRoute?) 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?) data class StreamDataIdentity(val settings: HeadwindSettings, val gpsLat: Double?, val gpsLon: Double?, val profile: UserProfile?, val routePolyline: LineString?)
@ -211,50 +208,29 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
requestedGpsCoordinates = mutableListOf(gps) requestedGpsCoordinates = mutableListOf(gps)
} }
val response = karooSystem.makeOpenMeteoHttpRequest(requestedGpsCoordinates, settings, profile) val response = try {
if (response.error != null){ WeatherProviderFactory.makeWeatherRequest(karooSystem, requestedGpsCoordinates, settings, profile)
try { } catch(e: Throwable){
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis())
val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis()) launch {
launch { saveStats(this@KarooHeadwindExtension, stats) }
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
}
error("HTTP request failed: ${response.error}")
} else {
try {
val responseBody = response.body?.let { String(it) }
var weatherDataProvider: WeatherDataProvider? = null
try { try {
if (responseBody != null) { saveStats(this@KarooHeadwindExtension, stats)
if (responseBody.trim().startsWith("[")) { } catch(e: Exception){
val responseArray = Log.e(TAG, "Failed to write stats", e)
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)
} }
val stats = lastKnownStats.copy(
lastSuccessfulWeatherRequest = System.currentTimeMillis(),
lastSuccessfulWeatherPosition = gps,
lastSuccessfulWeatherProvider = weatherDataProvider
)
launch { saveStats(this@KarooHeadwindExtension, stats) }
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
} }
throw e
}
try {
val stats = lastKnownStats.copy(
lastSuccessfulWeatherRequest = System.currentTimeMillis(),
lastSuccessfulWeatherPosition = gps,
lastSuccessfulWeatherProvider = response.provider
)
launch { saveStats(this@KarooHeadwindExtension, stats) }
} catch(e: Exception){
Log.e(TAG, "Failed to write stats", e)
} }
response response
@ -263,30 +239,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
delay(1.minutes); true delay(1.minutes); true
}.collect { response -> }.collect { response ->
try { try {
val inputStream = java.io.ByteArrayInputStream(response.body ?: ByteArray(0)) saveCurrentData(applicationContext, response)
val lowercaseHeaders = response.headers.map { (k: String, v: String) -> k.lowercase() to v.lowercase() }.toMap() Log.d(TAG, "Got updated weather info: $response")
val isGzippedResponse = lowercaseHeaders["content-encoding"]?.contains("gzip") == true
val responseString = if(isGzippedResponse){
val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) }
gzipStream.use { stream -> String(stream.readBytes()) }
} else {
inputStream.use { stream -> String(stream.readBytes()) }
}
if (requestedGpsCoordinates.size == 1){
val weatherData = jsonWithUnknownKeys.decodeFromString<OpenMeteoCurrentWeatherResponse>(responseString)
val data = WeatherDataResponse(weatherData, requestedGpsCoordinates.single())
saveCurrentData(applicationContext, listOf(data))
Log.d(TAG, "Got updated weather info: $data")
} else {
val weatherData = jsonWithUnknownKeys.decodeFromString<List<OpenMeteoCurrentWeatherResponse>>(responseString)
val data = weatherData.fastZip(requestedGpsCoordinates) { weather, gps -> WeatherDataResponse(weather, gps) }
saveCurrentData(applicationContext, data)
Log.d(TAG, "Got updated weather info: $data")
}
saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0)) saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0))
} catch(e: Exception){ } 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.content.Context
import android.util.Log import android.util.Log
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.models.DataPoint import io.hammerhead.karooext.models.DataPoint
@ -16,22 +17,24 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
abstract class BaseDataType( abstract class BaseDataType(
private val karooSystemService: KarooSystemService,
private val applicationContext: Context, private val applicationContext: Context,
dataTypeId: String dataTypeId: String
) : DataTypeImpl("karoo-headwind", dataTypeId) { ) : DataTypeImpl("karoo-headwind", dataTypeId) {
abstract fun getValue(data: OpenMeteoCurrentWeatherResponse): Double abstract fun getValue(data: WeatherData): Double?
override fun startStream(emitter: Emitter<StreamState>) { override fun startStream(emitter: Emitter<StreamState>) {
Log.d(KarooHeadwindExtension.TAG, "start $dataTypeId stream") Log.d(KarooHeadwindExtension.TAG, "start $dataTypeId stream")
val job = CoroutineScope(Dispatchers.IO).launch { val job = CoroutineScope(Dispatchers.IO).launch {
val currentWeatherData = applicationContext.streamCurrentWeatherData() val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystemService)
currentWeatherData currentWeatherData
.filterNotNull() .filterNotNull()
.collect { data -> .collect { data ->
val value = data.firstOrNull()?.data?.let { w -> getValue(w) } val value = getValue(data)
Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value")
if (value != null){
if (value != null) {
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
} else { } else {
emitter.onNext(StreamState.NotAvailable) emitter.onNext(StreamState.NotAvailable)

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class CloudCoverDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "cloudCover"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double? {
return data.current.cloudCover.toDouble() return data.cloudCover
} }
} }

View File

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

View File

@ -25,16 +25,16 @@ import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.HeadwindSettings
import de.timklge.karooheadwind.HeadwindWidgetSettings import de.timklge.karooheadwind.HeadwindWidgetSettings
import de.timklge.karooheadwind.KarooHeadwindExtension 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.R
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.UpcomingRoute import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataResponse import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.WeatherInterpretation 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.getHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentForecastWeatherData
import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUpcomingRoute import de.timklge.karooheadwind.streamUpcomingRoute
import de.timklge.karooheadwind.streamUserProfile 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()) 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 widgetSettings: HeadwindWidgetSettings? = null,
val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null) val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null)
@ -97,57 +97,63 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
while (true) { while (true) {
val data = (0..<10).map { index -> val data = (0..<10).map { index ->
val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond 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( val weatherData = (0..<12).map {
forecastTimes, val forecastTime = timeAtFullHour + it * 60 * 60
forecastTemperatures, val forecastTemperature = 20.0 + (-20..20).random()
forecastPrecipitationPropability, val forecastPrecipitation = 0.0 + (0..10).random()
forecastPrecipitation, val forecastPrecipitationProbability = (0..100).random()
forecastWeatherCodes, val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
forecastWindSpeed, val forecastWindSpeed = 0.0 + (0..10).random()
forecastWindDirection, val forecastWindDirection = 0.0 + (0..360).random()
forecastWindGusts 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 = val distancePerHour =
settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial) settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial)
?.toDouble() ?: 0.0 ?.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( emit(
StreamData( StreamData(
data, WeatherDataResponse(provider = WeatherDataProvider.OPEN_METEO, data = data),
SettingsAndProfile( SettingsAndProfile(
HeadwindSettings(), HeadwindSettings(),
settingsAndProfile?.isImperial == true, settingsAndProfile?.isImperial == true,
@ -182,7 +188,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
previewFlow(settingsAndProfileStream) previewFlow(settingsAndProfileStream)
} else { } else {
combine( combine(
context.streamCurrentWeatherData(), context.streamCurrentForecastWeatherData(),
settingsAndProfileStream, settingsAndProfileStream,
context.streamWidgetSettings(), context.streamWidgetSettings(),
karooSystem.getHeadingFlow(context), karooSystem.getHeadingFlow(context),
@ -204,7 +210,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
dataFlow.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, upcomingRoute) -> dataFlow.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, upcomingRoute) ->
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData.isNullOrEmpty()){ if (allData?.data.isNullOrEmpty()){
emitter.updateView( emitter.updateView(
getErrorWidget( getErrorWidget(
glance, glance,
@ -223,42 +229,29 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) { Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) {
val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 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 { var previousDate: String? = let {
val unixTime = val unixTime = allData?.data?.getOrNull(positionOffset)?.forecasts?.getOrNull(hourOffset)?.time
allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull(
hourOffset
)
val formattedDate = unixTime?.let { val formattedDate = unixTime?.let {
getShortDateFormatter().format( getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
Instant.ofEpochSecond(unixTime)
)
} }
formattedDate formattedDate
} }
for (baseIndex in hourOffset..hourOffset + 2) { 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 (allData?.data?.getOrNull(positionIndex) == null) break
if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size if (baseIndex >= (allData.data.getOrNull(positionOffset)?.forecasts?.size ?: 0)) break
?: 0)
) { val data = allData.data.getOrNull(positionIndex)
break 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}"
} }
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}"
}
if (baseIndex > hourOffset) { if (baseIndex > hourOffset) {
Spacer( Spacer(
modifier = GlanceModifier.fillMaxHeight().background( modifier = GlanceModifier.fillMaxHeight().background(
@ -280,8 +273,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val isCurrent = baseIndex == 0 && positionIndex == 0 val isCurrent = baseIndex == 0 && positionIndex == 0
if (isCurrent && data?.current != null) { if (isCurrent && data?.current != null) {
val interpretation = val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
WeatherInterpretation.fromWeatherCode(data.current.weatherCode)
val unixTime = data.current.time val unixTime = data.current.time
val formattedTime = val formattedTime =
timeFormatter.format(Instant.ofEpochSecond(unixTime)) timeFormatter.format(Instant.ofEpochSecond(unixTime))
@ -307,32 +299,22 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
previousDate = formattedDate previousDate = formattedDate
} else { } else {
val interpretation = WeatherInterpretation.fromWeatherCode( val weatherData = data?.forecasts?.getOrNull(baseIndex)
data?.forecastData?.weatherCode?.get(baseIndex) ?: 0 val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0)
) val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0
val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 0 val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedTime = val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
timeFormatter.format(Instant.ofEpochSecond(unixTime))
val formattedDate =
getShortDateFormatter().format(Instant.ofEpochSecond(unixTime))
val hasNewDate = formattedDate != previousDate || baseIndex == 0 val hasNewDate = formattedDate != previousDate || baseIndex == 0
RenderWidget( RenderWidget(
arrowBitmap = baseBitmap, arrowBitmap = baseBitmap,
current = interpretation, current = interpretation,
windBearing = data?.forecastData?.windDirection?.get(baseIndex) windBearing = weatherData?.windDirection?.roundToInt() ?: 0,
?.roundToInt() ?: 0, windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0,
windSpeed = data?.forecastData?.windSpeed?.get(baseIndex) windGusts = weatherData?.windGusts?.roundToInt() ?: 0,
?.roundToInt() ?: 0, precipitation = weatherData?.precipitation ?: 0.0,
windGusts = data?.forecastData?.windGusts?.get(baseIndex) precipitationProbability = weatherData?.precipitationProbability?.toInt(),
?.roundToInt() ?: 0, temperature = weatherData?.temperature?.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,
temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS, temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS,
timeLabel = formattedTime, timeLabel = formattedTime,
dateLabel = if (hasNewDate) formattedDate else null, 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.ContentScale
import androidx.glance.layout.Row import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding import androidx.glance.layout.padding
import androidx.glance.layout.width import androidx.glance.layout.width
import androidx.glance.layout.wrapContentWidth import androidx.glance.layout.wrapContentWidth
import androidx.glance.text.FontFamily import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight import androidx.glance.text.FontWeight
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue import kotlin.math.absoluteValue

View File

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

View File

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

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class PrecipitationDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "precipitation"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double {
return data.current.precipitation return data.precipitation
} }
} }

View File

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

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class RelativeHumidityDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "relativeHumidity"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double? {
return data.current.relativeHumidity.toDouble() return data.relativeHumidity
} }
} }

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class SealevelPressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "sealevelPressure"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double? {
return data.current.sealevelPressure ?: 0.0 return data.sealevelPressure
} }
} }

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class SurfacePressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "surfacePressure"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double? {
return data.current.surfacePressure return data.surfacePressure
} }
} }

View File

@ -119,11 +119,11 @@ class TailwindAndRideSpeedDataType(
val flow = if (config.preview) { val flow = if (config.preview) {
previewFlow(karooSystem.streamUserProfile()) previewFlow(karooSystem.streamUserProfile())
} else { } 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 isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
val absoluteWindDirection = weatherData.firstOrNull()?.data?.current?.windDirection val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData.firstOrNull()?.data?.current?.windSpeed val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData.firstOrNull()?.data?.current?.windGusts val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){ val rideSpeed = if (isImperial){
rideSpeedInMs * 2.23694 rideSpeedInMs * 2.23694
} else { } else {
@ -141,7 +141,7 @@ class TailwindAndRideSpeedDataType(
Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view") Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view")
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff 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 var headingResponse = streamData.headingResponse
if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){ 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.graphics.BitmapFactory
import android.util.Log import android.util.Log
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
@ -34,7 +33,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -99,11 +97,11 @@ class TailwindDataType(
val flow = if (config.preview) { val flow = if (config.preview) {
previewFlow(karooSystem.streamUserProfile()) previewFlow(karooSystem.streamUserProfile())
} else { } 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 isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
val absoluteWindDirection = weatherData.firstOrNull()?.data?.current?.windDirection val absoluteWindDirection = weatherData?.windDirection
val windSpeed = weatherData.firstOrNull()?.data?.current?.windSpeed val windSpeed = weatherData?.windSpeed
val gustSpeed = weatherData.firstOrNull()?.data?.current?.windGusts val gustSpeed = weatherData?.windGusts
val rideSpeed = if (isImperial){ val rideSpeed = if (isImperial){
rideSpeedInMs * 2.23694 rideSpeedInMs * 2.23694
} else { } else {

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class TemperatureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "temperature"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double {
return data.current.temperature return data.temperature
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ import androidx.glance.text.FontFamily
import androidx.glance.text.Text import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.weatherprovider.WeatherData
import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamDataFlow
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.internal.ViewEmitter import io.hammerhead.karooext.internal.ViewEmitter
@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.roundToInt 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) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
@ -48,8 +48,8 @@ class WindDirectionDataType(val karooSystem: KarooSystemService, context: Contex
) )
} }
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double {
return data.current.windDirection return data.windDirection
} }
private fun previewFlow(): Flow<Double> { 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.Row
import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding import androidx.glance.layout.padding
import androidx.glance.layout.width import androidx.glance.layout.width
import androidx.glance.text.FontFamily import androidx.glance.text.FontFamily
@ -26,10 +25,9 @@ import androidx.glance.text.Text
import androidx.glance.text.TextAlign import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.TemperatureUnit
import de.timklge.karooheadwind.WeatherInterpretation import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@Composable @Composable
fun WindForecast( fun WindForecast(

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double {
return data.current.windGusts return data.windGusts
} }
} }

View File

@ -1,10 +1,11 @@
package de.timklge.karooheadwind.datatypes package de.timklge.karooheadwind.datatypes
import android.content.Context 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"){ class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){
override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { override fun getValue(data: WeatherData): Double {
return data.current.windSpeed return data.windSpeed
} }
} }

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package de.timklge.karooheadwind package de.timklge.karooheadwind.util
import kotlin.math.abs 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 de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HttpResponseState
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
interface WeatherProvider { interface WeatherProvider {
@ -11,5 +11,5 @@ interface WeatherProvider {
coordinates: List<GpsCoordinates>, coordinates: List<GpsCoordinates>,
settings: HeadwindSettings, settings: HeadwindSettings,
profile: UserProfile? 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="green">#00ff00</color>
<color name="orange">#ff9930</color> <color name="orange">#ff9930</color>
<color name="red">#FF2424</color> <color name="red">#FF5454</color>
<color name="hGreen">#008000</color> <color name="hGreen">#008000</color>
<color name="hOrange">#BB4300</color> <color name="hOrange">#BB4300</color>
<color name="hRed">#B30000</color> <color name="hRed">#A30000</color>
</resources> </resources>