Compare commits

..

No commits in common. "87a244ef27cf01599d84d197f50a77071fa433df" and "302bdef42964225546b804ab500f9dbb71c037f4" have entirely different histories.

9 changed files with 31 additions and 123 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 "* 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", "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",
"screenshotUrls" to listOf( "screenshotUrls" to listOf(
"$baseUrl/preview1.png", "$baseUrl/preview1.png",
"$baseUrl/preview3.png", "$baseUrl/preview3.png",

View File

@ -5,8 +5,7 @@ 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 io.hammerhead.karooext.models.DataType import kotlinx.coroutines.awaitCancellation
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
@ -14,7 +13,6 @@ 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
@ -101,33 +99,7 @@ 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
} }
} }
.throttle(5_000L) .debounce(Duration.ofSeconds(5))
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(2.minutes); true delay(1.minutes); true
}.collect { response -> }.collect { response ->
try { try {
saveCurrentData(applicationContext, response) saveCurrentData(applicationContext, response)

View File

@ -70,8 +70,7 @@ 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> =
@ -206,7 +205,8 @@ 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, _, headingResponse, upcomingRoute) -> dataFlow.filter { it.isVisible }.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, actualUpcomingRoute) ->
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,12 +261,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
} }
} }
val pointData = getLineData( val pointData = getLineData(data, settingsAndProfile.isImperialTemperature, upcomingRoute)
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,8 +8,7 @@ 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,8 +8,7 @@ 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,8 +22,7 @@ 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
@ -42,16 +41,9 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
} }
val headwindPoints = try { val headwindPoints = try {
if (upcomingRoute != null || isPreview){ if (upcomingRoute != null){
(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()
@ -67,24 +59,9 @@ 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 = try { val coordsAlongRoute = TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS)
TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS) val nextCoordsAlongRoute = TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute + 5, TurfConstants.UNIT_METERS)
} catch(e: Exception) { val bearingAlongRoute = TurfMeasurement.bearing(coordsAlongRoute, nextCoordsAlongRoute)
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 (gridHeight > 15) (gridHeight / 10) else 1 val numYTicks = if (gridWidth > 15) 2 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,6 +6,7 @@ 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
@ -16,14 +17,12 @@ 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
@ -49,8 +48,6 @@ 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) {
@ -70,64 +67,33 @@ class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvide
coordinates: List<GpsCoordinates>, coordinates: List<GpsCoordinates>,
settings: HeadwindSettings, settings: HeadwindSettings,
profile: UserProfile? profile: UserProfile?
): WeatherDataResponse = coroutineScope { ): WeatherDataResponse {
val selectedCoordinates = coordinates.take((MAX_API_CALLS - 1).coerceAtLeast(1)).toMutableList()
if (coordinates.isNotEmpty() && !selectedCoordinates.contains(coordinates.last())){ val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey)
selectedCoordinates.add(coordinates.last()) val responseBody = response.body?.let { String(it) } ?: throw Exception("Null response from OpenWeatherMap")
}
Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap: searching for ${selectedCoordinates.size} locations from ${coordinates.size} total") val responses = mutableListOf<WeatherDataForLocation>()
selectedCoordinates.forEachIndexed { index, coord ->
Log.d(KarooHeadwindExtension.TAG, "Point #$index: ${coord.lat}, ${coord.lon}, distance: ${coord.distanceAlongRoute}")
}
val weatherDataForSelectedLocations = buildList { val openWeatherMapWeatherDataForLocation = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody)
for (coordinate in selectedCoordinates){ responses.add(openWeatherMapWeatherDataForLocation.toWeatherDataForLocation(null))
val response = makeOpenWeatherMapRequest(karooSystem, coordinate, apiKey)
val responseBody = response.body?.let { String(it) }
?: throw WeatherProviderException(response.statusCode, "Null Response from OpenWeatherMap")
val weatherData = jsonWithUnknownKeys.decodeFromString<OpenWeatherMapWeatherDataForLocation>(responseBody) // FIXME Route forecast
add(coordinate to weatherData) return WeatherDataResponse(
}
}
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 = allLocationData data = responses
) )
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private suspend fun makeOpenWeatherMapRequest( private suspend fun makeOpenWeatherMapRequest(
service: KarooSystemService, service: KarooSystemService,
coordinate: GpsCoordinates, coordinates: List<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}" +