From be7ca192b28f872d7ed8204f6fd4206ffed5e968 Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:40:45 +0200 Subject: [PATCH] Reintroduce circular headwind indicator data field with absolute wind speed (#160) * fix #158: Add circular absolute wind direction datafield * Move unittest package * Rework circular indicator * Shorten wind direction and speed label * Update README --- README.md | 2 +- .../karooheadwind/KarooHeadwindExtension.kt | 4 +- .../datatypes/HeadwindDirectionDataType.kt | 32 +--- .../timklge/karooheadwind/datatypes/Views.kt | 15 ++ .../WindDirectionAndSpeedDataTypeCircle.kt | 145 ++++++++++++++++++ app/src/main/res/values/strings.xml | 8 +- app/src/main/res/xml/extension_info.xml | 9 +- .../datatypes}/RelativeGradeTest.kt | 2 + 8 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataTypeCircle.kt rename app/src/test/kotlin/{ => de/timklge/karooheadwind/datatypes}/RelativeGradeTest.kt (99%) diff --git a/README.md b/README.md index d0bde84..db69e73 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ After installing this app on your Karoo and opening it once from the main menu, - Headwind (graphical, 1x1 field): Shows the headwind direction and speed as a circle with a triangular direction indicator. The speed is shown at the center in your set unit of measurement (default is kilometers per hour if you have set up metric units in your Karoo, otherwise miles per hour). Both direction and speed are relative to the current riding direction by default, i. e., riding directly into a wind of 20 km/h will show a headwind speed of 20 km/h, while riding in the same direction will show -20 km/h. - Tailwind with riding speed (graphical, 1x1 field): Shows an arrow indicating the current headwind direction next to a label reading your current speed and the speed of the tailwind. If you ride against a headwind of 5 mph, it will show "-5". If you ride in the same direction of a 5 mph wind, it will read "+5". Text and arrow are colored based on the tailwind speed, with red indicating a strong headwind and green indicating a strong tailwind. -- Wind direction and speed (graphical, 1x1 field): Similar to the tailwind data field, but shows the absolute wind speed and gust speed instead. +- Wind direction and speed (graphical, 1x1 field): Similar to the tailwind data field, but shows the absolute wind speed and gust speed instead. The "circular" variant uses the same circular graphics as the headwind indicator instead. - Wind forecast / Temperature Forecast / Precipitation forecast (graphical, 2x1 field): Line graphs showing the forecasted wind speeds, temperature or precipitation for the next 12 hours if no route is loaded. If a route is loaded, forecasts along the route will be used instead of the current location. - Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. If you have a route loaded, the forecast widget will show the forecasted weather along points of the route, with an estimated traveled distance per hour of 20 km / 12 miles by default. If placed in a 1x1 datafield, only the current weather conditions are shown. - Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind). diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index 83e7daf..cdc92a0 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -20,6 +20,7 @@ import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType +import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataTypeCircle import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindForecastDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType @@ -43,8 +44,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.time.debounce -import java.time.Duration import java.time.LocalDateTime import java.time.temporal.ChronoUnit import kotlin.math.absoluteValue @@ -66,6 +65,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS listOf( HeadwindDirectionDataType(karooSystem, applicationContext), TailwindAndRideSpeedDataType(karooSystem, applicationContext), + WindDirectionAndSpeedDataTypeCircle(karooSystem, applicationContext), WeatherForecastDataType(karooSystem), HeadwindSpeedDataType(karooSystem, applicationContext), RelativeHumidityDataType(karooSystem, applicationContext), diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt index 183e9d9..62c8ae2 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -67,24 +67,7 @@ class HeadwindDirectionDataType( val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff var returnValue = 0.0 - if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){ - var errorCode = 1.0 - var headingResponse = streamData.headingResponse - - if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){ - headingResponse = HeadingResponse.NoWeatherData - } - - if (streamData.settings.welcomeDialogAccepted == false){ - errorCode = ERROR_APP_NOT_SET_UP.toDouble() - } else if (headingResponse is HeadingResponse.NoGps){ - errorCode = ERROR_NO_GPS.toDouble() - } else { - errorCode = ERROR_NO_WEATHER_DATA.toDouble() - } - - returnValue = errorCode - } else { + if (value != null && streamData.absoluteWindDirection != null && streamData.windSpeed != null) { var windDirection = value if (windDirection < 0) windDirection += 360 @@ -222,15 +205,4 @@ class HeadwindDirectionDataType( } } } -} - -suspend fun KarooSystemService.getRefreshRateInMilliseconds(context: Context): Long { - val refreshRate = context.streamSettings(this).first().refreshRate - val isK2 = hardwareType == HardwareType.K2 - - return if (isK2){ - refreshRate.k2Ms - } else { - refreshRate.k3Ms - } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt index a20a6a6..4e94dbf 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt @@ -22,6 +22,10 @@ import androidx.glance.text.TextStyle import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.streamSettings +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.HardwareType +import kotlinx.coroutines.flow.first @OptIn(ExperimentalGlanceRemoteViewsApi::class) suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { @@ -75,4 +79,15 @@ suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, errorCod ) } } +} + +suspend fun KarooSystemService.getRefreshRateInMilliseconds(context: Context): Long { + val refreshRate = context.streamSettings(this).first().refreshRate + val isK2 = hardwareType == HardwareType.K2 + + return if (isK2){ + refreshRate.k2Ms + } else { + refreshRate.k3Ms + } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataTypeCircle.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataTypeCircle.kt new file mode 100644 index 0000000..b4d563b --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataTypeCircle.kt @@ -0,0 +1,145 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import android.graphics.BitmapFactory +import android.util.Log +import androidx.compose.ui.unit.DpSize +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType.StreamData +import de.timklge.karooheadwind.getRelativeHeadingFlow +import de.timklge.karooheadwind.streamCurrentWeatherData +import de.timklge.karooheadwind.streamDatatypeIsVisible +import de.timklge.karooheadwind.streamSettings +import de.timklge.karooheadwind.streamUserProfile +import de.timklge.karooheadwind.throttle +import de.timklge.karooheadwind.util.msInUserUnit +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.extension.DataTypeImpl +import io.hammerhead.karooext.internal.Emitter +import io.hammerhead.karooext.internal.ViewEmitter +import io.hammerhead.karooext.models.DataPoint +import io.hammerhead.karooext.models.DataType +import io.hammerhead.karooext.models.HardwareType +import io.hammerhead.karooext.models.StreamState +import io.hammerhead.karooext.models.UpdateGraphicConfig +import io.hammerhead.karooext.models.UserProfile +import io.hammerhead.karooext.models.ViewConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlin.math.cos +import kotlin.math.roundToInt + +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +class WindDirectionAndSpeedDataTypeCircle( + private val karooSystem: KarooSystemService, + private val applicationContext: Context +) : DataTypeImpl("karoo-headwind", "windDirectionAndSpeedCircle") { + private val glance = GlanceRemoteViews() + + data class StreamData(val headingResponse: HeadingResponse, val absoluteWindDirection: Double?, val windSpeed: Double?, val settings: HeadwindSettings) + + private fun previewFlow(profileFlow: Flow): Flow { + return flow { + val profile = profileFlow.first() + + while (true) { + val bearing = (0..360).random().toDouble() + val windSpeed = (0..10).random() + val gustSpeed = windSpeed * ((10..20).random().toDouble() / 10) + val isImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL + + emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings(), gustSpeed = gustSpeed, isImperial = isImperial, isVisible = true)) + + delay(2_000) + } + } + } + + override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) { + Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter") + + val baseBitmap = BitmapFactory.decodeResource( + context.resources, + de.timklge.karooheadwind.R.drawable.circle + ) + + val configJob = CoroutineScope(Dispatchers.IO).launch { + emitter.onNext(UpdateGraphicConfig(showHeader = false)) + awaitCancellation() + } + + val flow = if (config.preview) { + previewFlow(karooSystem.streamUserProfile()) + } else { + combine(karooSystem.getRelativeHeadingFlow(context), + context.streamCurrentWeatherData(karooSystem), + context.streamSettings(karooSystem), + karooSystem.streamUserProfile(), + karooSystem.streamDatatypeIsVisible(dataTypeId) + ) { headingResponse, weatherData, settings, userProfile, isVisible -> + val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL + val absoluteWindDirection = weatherData?.windDirection + val windSpeed = weatherData?.windSpeed + val gustSpeed = weatherData?.windGusts + + StreamData(headingResponse, absoluteWindDirection, windSpeed, settings, isImperial = isImperial, gustSpeed = gustSpeed, isVisible = isVisible) + } + } + + val viewJob = CoroutineScope(Dispatchers.IO).launch { + val refreshRate = karooSystem.getRefreshRateInMilliseconds(context) + + flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData -> + Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view") + + val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff + if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){ + var headingResponse = streamData.headingResponse + + if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){ + headingResponse = HeadingResponse.NoWeatherData + } + + emitter.updateView(getErrorWidget(glance, context, streamData.settings, headingResponse).remoteViews) + + return@collect + } + + val windSpeed = streamData.windSpeed + val windSpeedUserUnit = msInUserUnit(windSpeed, streamData.isImperial) + + val result = glance.compose(context, DpSize.Unspecified) { + HeadwindDirection( + baseBitmap, + value.roundToInt(), + config.textSize, + windSpeedUserUnit.roundToInt().toString(), + preview = config.preview, + wideMode = false + ) + } + + emitter.updateView(result.remoteViews) + } + } + emitter.setCancellable { + Log.d(KarooHeadwindExtension.TAG, "Stopping headwind view with $emitter") + configJob.cancel() + viewJob.cancel() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 403e056..40b3880 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,11 +10,11 @@ Cloud cover Current cloud cover in percent Wind speed - Current wind speed in configured unit + Current absolute wind speed Wind gusts Current wind gust speed in configured unit Absolute wind direction - Current wind direction + Current absolute wind direction Rainfall Current precipitation (rainfall / snowfall) Surface pressure @@ -39,6 +39,8 @@ Perceived grade in percent Relative Elevation Gain Perceived elevation gain in meters - Wind direction and speed + Wind direction, speed Current wind direction and wind speed + Wind direction, speed (Circle) + Current wind direction and wind speed (Circle graphics) \ No newline at end of file diff --git a/app/src/main/res/xml/extension_info.xml b/app/src/main/res/xml/extension_info.xml index b00b79a..f10c54d 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -22,10 +22,17 @@ + +