Compare commits

..

7 Commits

Author SHA1 Message Date
87a244ef27 Merge remote-tracking branch 'timklge/master'
All checks were successful
Build / build (push) Successful in 5m37s
2025-06-11 20:57:39 +02:00
04fb321a40 Update changelog 2025-06-11 20:56:45 +02:00
timklge
b6d0acd62d
Request first two and last location for openweathermap route forecast (#154) 2025-06-11 20:54:53 +02:00
timklge
533cb1e006
Show headwind line in wind forecast preview (#153) 2025-06-11 20:43:08 +02:00
timklge
1727e606ee
Scale number of y ticks in linegraph data fields (#150) 2025-06-11 20:17:02 +02:00
timklge
f35ffe52cc
Fix initial position is only retrieved on position change (#149) 2025-06-11 19:39:11 +02:00
Enderthor
5b163f6f7a
Add route forecast support for OpenWeatherMap (#145)
* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* Added openweathermap support

* timklge updates 20250308
Forecast in route (openweathermap)

* Forecast in route (openweathermap)

* Forecast in route (openweathermap)

* Update app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-11 19:21:48 +02:00
9 changed files with 123 additions and 31 deletions

View File

@ -72,7 +72,7 @@ tasks.register("generateManifest") {
"latestVersionCode" to android.defaultConfig.versionCode, "latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge", "developer" to "github.com/timklge",
"description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.", "description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.",
"releaseNotes" to "* Crop timespan in forecast widgets to 6 hours, use am / pm time format, enlarge font\n* Remove tailwind, icon forecast, weather data fields", "releaseNotes" to "* Add route forecast support for OpenWeatherMap (thx @lockevod!)\n* Crop timespan in forecast widgets to 6 hours, use am / pm time format, enlarge font\n* Remove tailwind, icon forecast, weather data fields",
"screenshotUrls" to listOf( "screenshotUrls" to listOf(
"$baseUrl/preview1.png", "$baseUrl/preview1.png",
"$baseUrl/preview3.png", "$baseUrl/preview3.png",

View File

@ -5,7 +5,8 @@ import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.util.signedAngleDifference import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import kotlinx.coroutines.awaitCancellation import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState
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,6 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll 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.firstOrNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -99,7 +101,33 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinat
val initialFlow = flow { val initialFlow = flow {
val lastKnownPosition = context.getLastKnownPosition() val lastKnownPosition = context.getLastKnownPosition()
if (lastKnownPosition == null) {
val initialState = streamDataFlow(DataType.Type.LOCATION).firstOrNull()?.let { it as? StreamState.Streaming }
initialState?.dataPoint?.let { dataPoint ->
val lat = dataPoint.values[DataType.Field.LOC_LATITUDE]
val lng = dataPoint.values[DataType.Field.LOC_LONGITUDE]
val orientation = dataPoint.values[DataType.Field.LOC_BEARING]
if (lat != null && lng != null) {
emit(GpsCoordinates(lat, lng, orientation))
Log.i(KarooHeadwindExtension.TAG, "No last known position found, fetched initial GPS position")
} else {
emit(null)
Log.w(KarooHeadwindExtension.TAG, "No last known position found, initial GPS position is unavailable")
}
} ?: run {
emit(null)
Log.w(KarooHeadwindExtension.TAG, "No last known position found, initial GPS position is unavailable")
}
} else {
emit(lastKnownPosition) emit(lastKnownPosition)
Log.i(KarooHeadwindExtension.TAG, "Using last known position: $lastKnownPosition")
}
} }
val gpsFlow = streamLocation() val gpsFlow = streamLocation()

View File

@ -115,7 +115,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
old == new old == new
} }
} }
.debounce(Duration.ofSeconds(5)) .throttle(5_000L)
var requestedGpsCoordinates: List<GpsCoordinates> = emptyList() var requestedGpsCoordinates: List<GpsCoordinates> = emptyList()
@ -234,7 +234,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
response response
}.retry(Long.MAX_VALUE) { e -> }.retry(Long.MAX_VALUE) { e ->
Log.w(TAG, "Failed to get weather data", e) Log.w(TAG, "Failed to get weather data", e)
delay(1.minutes); true delay(2.minutes); true
}.collect { response -> }.collect { response ->
try { try {
saveCurrentData(applicationContext, response) saveCurrentData(applicationContext, response)

View File

@ -70,7 +70,8 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
abstract fun getLineData( abstract fun getLineData(
lineData: List<LineData>, lineData: List<LineData>,
isImperial: Boolean, isImperial: Boolean,
upcomingRoute: UpcomingRoute? upcomingRoute: UpcomingRoute?,
isPreview: Boolean
): Set<LineGraphBuilder.Line> ): Set<LineGraphBuilder.Line>
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> = private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> =
@ -205,8 +206,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val viewJob = CoroutineScope(Dispatchers.IO).launch { val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null)) emitter.onNext(ShowCustomStreamState("", null))
dataFlow.filter { it.isVisible }.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, actualUpcomingRoute) -> dataFlow.filter { it.isVisible }.collect { (allData, settingsAndProfile, _, headingResponse, upcomingRoute) ->
val upcomingRoute = if (allData?.provider == WeatherDataProvider.OPEN_METEO) actualUpcomingRoute else null
Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view")
if (allData?.data.isNullOrEmpty()){ if (allData?.data.isNullOrEmpty()){
@ -261,7 +261,12 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
} }
} }
val pointData = getLineData(data, settingsAndProfile.isImperialTemperature, upcomingRoute) val pointData = getLineData(
data,
settingsAndProfile.isImperialTemperature,
upcomingRoute,
config.preview
)
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x -> val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
val startTime = data.firstOrNull()?.time val startTime = data.firstOrNull()?.time
val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS) val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS)

View File

@ -8,7 +8,8 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
override fun getLineData( override fun getLineData(
lineData: List<LineData>, lineData: List<LineData>,
isImperial: Boolean, isImperial: Boolean,
upcomingRoute: UpcomingRoute? upcomingRoute: UpcomingRoute?,
isPreview: Boolean
): Set<LineGraphBuilder.Line> { ): Set<LineGraphBuilder.Line> {
val precipitationPoints = lineData.map { data -> val precipitationPoints = lineData.map { data ->
if (isImperial) { // Convert mm to inches if (isImperial) { // Convert mm to inches

View File

@ -8,7 +8,8 @@ class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphFo
override fun getLineData( override fun getLineData(
lineData: List<LineData>, lineData: List<LineData>,
isImperial: Boolean, isImperial: Boolean,
upcomingRoute: UpcomingRoute? upcomingRoute: UpcomingRoute?,
isPreview: Boolean
): Set<LineGraphBuilder.Line> { ): Set<LineGraphBuilder.Line> {
val linePoints = lineData.map { data -> val linePoints = lineData.map { data ->
if (isImperial) { if (isImperial) {

View File

@ -22,7 +22,8 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
override fun getLineData( override fun getLineData(
lineData: List<LineData>, lineData: List<LineData>,
isImperial: Boolean, isImperial: Boolean,
upcomingRoute: UpcomingRoute? upcomingRoute: UpcomingRoute?,
isPreview: Boolean
): Set<LineGraphBuilder.Line> { ): Set<LineGraphBuilder.Line> {
val windPoints = lineData.map { data -> val windPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph if (isImperial) { // Convert m/s to mph
@ -41,9 +42,16 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
} }
val headwindPoints = try { val headwindPoints = try {
if (upcomingRoute != null){ if (upcomingRoute != null || isPreview){
(0..<HEADWIND_SAMPLE_COUNT).mapNotNull { i -> (0..<HEADWIND_SAMPLE_COUNT).mapNotNull { i ->
val t = i / HEADWIND_SAMPLE_COUNT.toDouble() val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
if (isPreview) {
return@mapNotNull LineGraphBuilder.DataPoint(i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()), (-10f) + (20f * kotlin.random.Random.nextFloat()))
}
if (upcomingRoute == null) return@mapNotNull null
val beforeLineData = lineData.getOrNull(floor(lineData.size * t).toInt()) ?: lineData.firstOrNull() val beforeLineData = lineData.getOrNull(floor(lineData.size * t).toInt()) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil(lineData.size * t).toInt()) ?: lineData.lastOrNull() val afterLineData = lineData.getOrNull(ceil(lineData.size * t).toInt()) ?: lineData.lastOrNull()
@ -59,9 +67,24 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
val beforeDistanceAlongRoute = beforeLineData.distance val beforeDistanceAlongRoute = beforeLineData.distance
val afterDistanceAlongRoute = afterLineData.distance val afterDistanceAlongRoute = afterLineData.distance
val distanceAlongRoute = (beforeDistanceAlongRoute + (afterDistanceAlongRoute - beforeDistanceAlongRoute) * dt).coerceIn(0.0, upcomingRoute.routeLength) val distanceAlongRoute = (beforeDistanceAlongRoute + (afterDistanceAlongRoute - beforeDistanceAlongRoute) * dt).coerceIn(0.0, upcomingRoute.routeLength)
val coordsAlongRoute = TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS) val coordsAlongRoute = try {
val nextCoordsAlongRoute = TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute + 5, TurfConstants.UNIT_METERS) TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS)
val bearingAlongRoute = TurfMeasurement.bearing(coordsAlongRoute, nextCoordsAlongRoute) } catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error getting coordinates along route", e)
return@mapNotNull null
}
val nextCoordsAlongRoute = try {
TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute + 5, TurfConstants.UNIT_METERS)
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error getting next coordinates along route", e)
return@mapNotNull null
}
val bearingAlongRoute = try {
TurfMeasurement.bearing(coordsAlongRoute, nextCoordsAlongRoute)
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error calculating bearing along route", e)
return@mapNotNull null
}
val windBearing = interpolatedWeather.windDirection + 180 val windBearing = interpolatedWeather.windDirection + 180
val diff = signedAngleDifference(bearingAlongRoute, windBearing) val diff = signedAngleDifference(bearingAlongRoute, windBearing)
val headwindSpeed = cos( (diff + 180) * Math.PI / 180.0) * interpolatedWeather.windSpeed val headwindSpeed = cos( (diff + 180) * Math.PI / 180.0) * interpolatedWeather.windSpeed

View File

@ -381,7 +381,7 @@ class LineGraphBuilder(val context: Context) {
// Draw Left Y-axis ticks and labels // Draw Left Y-axis ticks and labels
if (hasLeftYAxisData) { if (hasLeftYAxisData) {
textPaint.textAlign = Align.RIGHT textPaint.textAlign = Align.RIGHT
val numYTicks = if (gridWidth > 15) 2 else 1 val numYTicks = if (gridHeight > 15) (gridHeight / 10) else 1
if (abs(dataMaxYLeft - dataMinYLeft) > 0.0001f) { if (abs(dataMaxYLeft - dataMinYLeft) > 0.0001f) {
for (i in 0..numYTicks) { for (i in 0..numYTicks) {
val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicks) * i val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicks) * i

View File

@ -6,7 +6,6 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.WeatherDataProvider import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.jsonWithUnknownKeys import de.timklge.karooheadwind.jsonWithUnknownKeys
import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation
import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse
import de.timklge.karooheadwind.weatherprovider.WeatherProvider import de.timklge.karooheadwind.weatherprovider.WeatherProvider
import de.timklge.karooheadwind.weatherprovider.WeatherProviderException import de.timklge.karooheadwind.weatherprovider.WeatherProviderException
@ -17,12 +16,14 @@ import io.hammerhead.karooext.models.UserProfile
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.single
import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.flow.timeout
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -48,6 +49,8 @@ data class Snow(
class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvider { class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvider {
companion object { companion object {
private const val MAX_API_CALLS = 3
fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int { fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int {
// Mapping OpenWeatherMap to WMO OpenMeteo // Mapping OpenWeatherMap to WMO OpenMeteo
return when (owmCode) { return when (owmCode) {
@ -67,33 +70,64 @@ class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvide
coordinates: List<GpsCoordinates>, coordinates: List<GpsCoordinates>,
settings: HeadwindSettings, settings: HeadwindSettings,
profile: UserProfile? profile: UserProfile?
): WeatherDataResponse { ): WeatherDataResponse = coroutineScope {
val selectedCoordinates = coordinates.take((MAX_API_CALLS - 1).coerceAtLeast(1)).toMutableList()
val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey) if (coordinates.isNotEmpty() && !selectedCoordinates.contains(coordinates.last())){
val responseBody = response.body?.let { String(it) } ?: throw Exception("Null response from OpenWeatherMap") selectedCoordinates.add(coordinates.last())
}
val responses = mutableListOf<WeatherDataForLocation>() Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap: searching for ${selectedCoordinates.size} locations from ${coordinates.size} total")
selectedCoordinates.forEachIndexed { index, coord ->
Log.d(KarooHeadwindExtension.TAG, "Point #$index: ${coord.lat}, ${coord.lon}, distance: ${coord.distanceAlongRoute}")
}
val openWeatherMapWeatherDataForLocation = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody) val weatherDataForSelectedLocations = buildList {
responses.add(openWeatherMapWeatherDataForLocation.toWeatherDataForLocation(null)) for (coordinate in selectedCoordinates){
val response = makeOpenWeatherMapRequest(karooSystem, coordinate, apiKey)
val responseBody = response.body?.let { String(it) }
?: throw WeatherProviderException(response.statusCode, "Null Response from OpenWeatherMap")
// FIXME Route forecast val weatherData = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody)
return WeatherDataResponse( add(coordinate to weatherData)
}
}
val allLocationData = coordinates.map { originalCoord ->
val directMatch = weatherDataForSelectedLocations.find { it.first == originalCoord }
if (directMatch != null) {
directMatch.second.toWeatherDataForLocation(originalCoord.distanceAlongRoute)
} else {
val closestCoord = weatherDataForSelectedLocations.minByOrNull { (coord, _) ->
if (originalCoord.distanceAlongRoute != null && coord.distanceAlongRoute != null) {
(originalCoord.distanceAlongRoute - coord.distanceAlongRoute).absoluteValue
} else {
originalCoord.distanceTo(coord)
}
} ?: throw WeatherProviderException(500, "Error finding nearest coordinate")
closestCoord.second.toWeatherDataForLocation(originalCoord.distanceAlongRoute)
}
}
WeatherDataResponse(
provider = WeatherDataProvider.OPEN_WEATHER_MAP, provider = WeatherDataProvider.OPEN_WEATHER_MAP,
data = responses data = allLocationData
) )
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private suspend fun makeOpenWeatherMapRequest( private suspend fun makeOpenWeatherMapRequest(
service: KarooSystemService, service: KarooSystemService,
coordinates: List<GpsCoordinates>, coordinate: GpsCoordinates,
apiKey: String apiKey: String
): HttpResponseState.Complete { ): HttpResponseState.Complete {
val response = callbackFlow { val response = callbackFlow {
// OpenWeatherMap only supports setting imperial or metric units for all measurements, not individually for distance / temperature
val coordinate = coordinates.first()
// URL API 3.0 with onecall endpoint // URL API 3.0 with onecall endpoint
val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" + val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" +