ref #35: Set headwind data type field to direction according to user setting, add userwind data type (#36)

* ref #35: Set headwind data type fields to values according to user settings

* Use single datafield per type, add userwindSpeed data type to expose (head-)wind speed depending on user setting

* Enable minification for release builds

* Fix signed angle difference in headwind view

* Remove bitmap cache

* Format
This commit is contained in:
timklge 2025-02-08 14:28:30 +01:00 committed by GitHub
parent 0869121176
commit 01959ce3b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 233 additions and 89 deletions

View File

@ -45,3 +45,14 @@ The app will automatically attempt to download weather data for your current app
- Icons are from [boxicons.com](https://boxicons.com) ([MIT-licensed](icon_credits.txt))
- Made possible by the generous usage terms of [open-meteo.com](https://open-meteo.com)
- Uses [karoo-ext](https://github.com/hammerheadnav/karoo-ext) (Apache2-licensed)
## Extension Developers: Headwind Data Type
If the user has installed the headwind extension on his karoo, you can stream the headwind data type from other extensions via `karoo-ext`.
Use extension id `karoo-headwind` with datatype ids `headwind` and `userwindSpeed`.
- The `headwind` datatype contains a single field that either represents an error code or the wind direction. A `-1.0` indicates missing gps receiption, `-2.0` no weather data, `-3.0` that the headwind extension
has not been set up. Otherwise, the value is the wind direction in degrees; if the user has set the headwind indicator to depict the absolute wind direction, the field will contain the absolute wind direction; otherwise
it will contain the headwind direction.
- The `userwindSpeed` datatype contains a single field with the wind speed in the user's defined unit. If the user has set the headwind indicator to show the absolute wind speed,
this field will contain the absolute wind speed; otherwise it will contain the headwind speed.

View File

@ -15,8 +15,8 @@ android {
applicationId = "de.timklge.karooheadwind"
minSdk = 26
targetSdk = 35
versionCode = 10
versionName = "1.2.2"
versionCode = 11
versionName = "1.2.3"
}
signingConfigs {
@ -38,7 +38,7 @@ android {
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

View File

@ -3,9 +3,9 @@
"packageName": "de.timklge.karooheadwind",
"iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png",
"latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk",
"latestVersion": "1.2.2",
"latestVersionCode": 10,
"latestVersion": "1.2.3",
"latestVersionCode": 11,
"developer": "timklge",
"description": "Provides headwind direction, wind speed and other weather data fields",
"releaseNotes": "Reduce font size in tailwind indicators, update gps bearing retrieval"
"releaseNotes": "Update gps bearing retrieval, data type exposure for other extensions, optimize build"
}

View File

@ -4,8 +4,6 @@ import android.content.Context
import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -15,7 +13,6 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
sealed class HeadingResponse {
@ -35,7 +32,7 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
val windBearing = data.current.windDirection + 180
val diff = signedAngleDifference(bearing.diff, windBearing)
Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff")
Log.d(KarooHeadwindExtension.TAG, "Wind bearing: Heading $bearing vs wind $windBearing => $diff")
HeadingResponse.Value(diff)
}

View File

@ -12,6 +12,7 @@ import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.UserWindSpeedDataType
import de.timklge.karooheadwind.datatypes.WeatherDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindSpeedDataType
@ -41,12 +42,11 @@ import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.2") {
class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.3") {
companion object {
const val TAG = "karoo-headwind"
}
lateinit var karooSystem: KarooSystemService
private var updateLastKnownGpsJob: Job? = null
@ -67,7 +67,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.2.2") {
TemperatureDataType(applicationContext),
WindDirectionDataType(karooSystem, applicationContext),
PrecipitationDataType(applicationContext),
SurfacePressureDataType(applicationContext)
SurfacePressureDataType(applicationContext),
UserWindSpeedDataType(karooSystem, applicationContext)
)
}

View File

@ -11,8 +11,8 @@ import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting
import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamSettings
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
@ -30,8 +30,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlin.math.cos
import kotlin.math.roundToInt
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
@ -44,9 +45,41 @@ class HeadwindDirectionDataType(
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
karooSystem.getRelativeHeadingFlow(applicationContext)
.collect { diff ->
val value = (diff as? HeadingResponse.Value)?.diff ?: 0.0
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value))))
.combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data -> StreamData(headingResponse, data?.current?.windDirection, data?.current?.windSpeed) }
.combine(applicationContext.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) }
.collect { streamData ->
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
var returnValue = 0.0
if (value == null || streamData.absoluteWindDirection == null || streamData.settings == 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 {
var windDirection = when (streamData.settings.windDirectionIndicatorSetting){
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> value
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
if (windDirection < 0) windDirection += 360
returnValue = windDirection
}
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to returnValue))))
}
}
emitter.setCancellable {
@ -56,13 +89,16 @@ class HeadwindDirectionDataType(
data class StreamData(val headingResponse: HeadingResponse?, val absoluteWindDirection: Double?, val windSpeed: Double?, val settings: HeadwindSettings? = null)
private fun previewFlow(): Flow<StreamData> {
data class DirectionAndSpeed(val bearing: Double, val speed: Double?)
private fun previewFlow(): Flow<DirectionAndSpeed> {
return flow {
while (true) {
val bearing = (0..360).random().toDouble()
val windSpeed = (0..20).random()
emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings()))
emit(DirectionAndSpeed(bearing, windSpeed.toDouble()))
delay(2_000)
}
}
@ -85,44 +121,34 @@ class HeadwindDirectionDataType(
val flow = if (config.preview) {
previewFlow()
} else {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { headingResponse, data -> StreamData(headingResponse, data?.current?.windDirection, data?.current?.windSpeed) }
.combine(context.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) }
val directionFlow = karooSystem.streamDataFlow(dataTypeId).mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
val speedFlow = karooSystem.streamDataFlow(DataType.dataTypeId("karoo-headwind", "userwindSpeed")).map { (it as? StreamState.Streaming)?.dataPoint?.singleValue }
combine(directionFlow, speedFlow) { direction, speed ->
DirectionAndSpeed(direction, speed)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
flow.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.settings == 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)
val errorCode = streamData.bearing.let { if(it < 0) it.toInt() else null }
if (errorCode != null) {
emitter.updateView(getErrorWidget(glance, context, errorCode).remoteViews)
return@collect
}
val windSpeed = streamData.windSpeed
val windDirection = when (streamData.settings.windDirectionIndicatorSetting){
WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> value
WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180
}
val text = when (streamData.settings.windDirectionIndicatorTextSetting) {
WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> {
val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed
headwindSpeed.roundToInt().toString()
}
WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString()
WindDirectionIndicatorTextSetting.NONE -> ""
}
val windDirection = streamData.bearing
val windSpeed = streamData.speed
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text, viewSize = config.viewSize)
HeadwindDirection(
baseBitmap,
windDirection.roundToInt(),
config.textSize,
windSpeed?.toInt()?.toString() ?: ""
)
}
emitter.updateView(result.remoteViews)
@ -134,4 +160,10 @@ class HeadwindDirectionDataType(
viewJob.cancel()
}
}
companion object {
const val ERROR_NO_GPS = -1
const val ERROR_NO_WEATHER_DATA = -2
const val ERROR_APP_NOT_SET_UP = -3
}
}

View File

@ -22,7 +22,6 @@ import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.text.FontFamily
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
@ -30,47 +29,34 @@ import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.KarooHeadwindExtension
import kotlin.math.roundToInt
data class BitmapWithBearing(val bitmap: Bitmap, val bearing: Int)
val bitmapsByBearing = mutableMapOf<BitmapWithBearing, Bitmap>()
fun getArrowBitmapByBearing(baseBitmap: Bitmap, bearing: Int): Bitmap {
synchronized(bitmapsByBearing) {
val bearingRounded = (((bearing + 360) / 10.0).roundToInt() * 10) % 360
val bearingRounded = (((bearing + 360) / 10.0).roundToInt() * 10) % 360
val bitmapWithBearing = BitmapWithBearing(baseBitmap, bearingRounded)
val storedBitmap = bitmapsByBearing[bitmapWithBearing]
if (storedBitmap != null) return storedBitmap
val bitmap = Bitmap.createBitmap(128, 128, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val bitmap = Bitmap.createBitmap(128, 128, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val paint = Paint().apply {
color = android.graphics.Color.BLACK
style = Paint.Style.STROKE
// strokeWidth = 15f
isAntiAlias = true
}
canvas.save()
canvas.scale((bitmap.width / baseBitmap.width.toFloat()), (bitmap.height / baseBitmap.height.toFloat()), (bitmap.width / 2).toFloat(), (bitmap.height / 2).toFloat())
Log.d(KarooHeadwindExtension.TAG, "Drawing arrow at $bearingRounded")
canvas.rotate(bearing.toFloat(), (bitmap.width / 2).toFloat(), (bitmap.height / 2).toFloat())
canvas.drawBitmap(baseBitmap, ((bitmap.width - baseBitmap.width) / 2).toFloat(), ((bitmap.height - baseBitmap.height) / 2).toFloat(), paint)
canvas.restore()
bitmapsByBearing[bitmapWithBearing] = bitmap
return bitmap
val paint = Paint().apply {
color = android.graphics.Color.BLACK
style = Paint.Style.STROKE
isAntiAlias = true
}
canvas.save()
canvas.scale((bitmap.width / baseBitmap.width.toFloat()), (bitmap.height / baseBitmap.height.toFloat()), (bitmap.width / 2).toFloat(), (bitmap.height / 2).toFloat())
Log.d(KarooHeadwindExtension.TAG, "Drawing arrow at $bearingRounded")
canvas.rotate(bearingRounded.toFloat(), (bitmap.width / 2).toFloat(), (bitmap.height / 2).toFloat())
canvas.drawBitmap(baseBitmap, ((bitmap.width - baseBitmap.width) / 2).toFloat(), ((bitmap.height - baseBitmap.height) / 2).toFloat(), paint)
canvas.restore()
return bitmap
}
@OptIn(ExperimentalGlancePreviewApi::class)
@Composable
fun HeadwindDirection(baseBitmap: Bitmap, bearing: Int, fontSize: Int,
overlayText: String, overlaySubText: String? = null,
dayColor: Color = Color.Black, nightColor: Color = Color.White,
viewSize: Pair<Int, Int>) {
fun HeadwindDirection(
baseBitmap: Bitmap, bearing: Int, fontSize: Int,
overlayText: String, overlaySubText: String? = null,
dayColor: Color = Color.Black, nightColor: Color = Color.White
) {
Box(
modifier = GlanceModifier.fillMaxSize().padding(5.dp),
contentAlignment = Alignment(

View File

@ -45,4 +45,5 @@ class HeadwindSpeedDataType(
job.cancel()
}
}
}
}

View File

@ -199,8 +199,15 @@ class TailwindAndRideSpeedDataType(
}
val result = glance.compose(context, DpSize.Unspecified) {
HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text, subtextWithSign,
dayColor, nightColor, viewSize = config.viewSize)
HeadwindDirection(
baseBitmap,
windDirection.roundToInt(),
config.textSize,
text,
subtextWithSign,
dayColor,
nightColor
)
}
emitter.updateView(result.remoteViews)

View File

@ -0,0 +1,69 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.HeadwindSettings
import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamSettings
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.Emitter
import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlin.math.cos
class UserWindSpeedDataType(
private val karooSystem: KarooSystemService,
private val context: Context
) : DataTypeImpl("karoo-headwind", "userwindSpeed"){
data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings)
override fun startStream(emitter: Emitter<StreamState>) {
val job = CoroutineScope(Dispatchers.IO).launch {
karooSystem.getRelativeHeadingFlow(context)
.combine(context.streamCurrentWeatherData()) { value, data -> value to data }
.combine(context.streamSettings(karooSystem)) { (value, data), settings -> StreamData(value, data, settings) }
.filter { it.weatherResponse != null }
.collect { streamData ->
val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0
val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0
if (streamData.settings.windDirectionIndicatorTextSetting == WindDirectionIndicatorTextSetting.HEADWIND_SPEED){
val headwindSpeed = cos((windDirection + 180) * Math.PI / 180.0) * windSpeed
emitter.onNext(
StreamState.Streaming(
DataPoint(
dataTypeId,
mapOf(DataType.Field.SINGLE to headwindSpeed)
)
)
)
} else {
emitter.onNext(
StreamState.Streaming(
DataPoint(
dataTypeId,
mapOf(DataType.Field.SINGLE to windSpeed)
)
)
)
}
}
}
emitter.setCancellable {
job.cancel()
}
}
}

View File

@ -37,10 +37,41 @@ suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings
Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage")
Text(text = errorMessage, style = TextStyle(fontSize = TextUnit(16f, TextUnitType.Sp),
textAlign = TextAlign.Center,
color = ColorProvider(Color.Black, Color.White)
Text(text = errorMessage,
style = TextStyle(
fontSize = TextUnit(16f, TextUnitType.Sp),
textAlign = TextAlign.Center,
color = ColorProvider(Color.Black, Color.White)
)
)
}
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, errorCode: Int): RemoteViewsCompositionResult {
return glance.compose(context, DpSize.Unspecified) {
Box(modifier = GlanceModifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.Center) {
val errorMessage = when (errorCode) {
HeadwindDirectionDataType.ERROR_APP_NOT_SET_UP -> {
"Headwind app not set up"
}
HeadwindDirectionDataType.ERROR_NO_GPS -> {
"No GPS signal"
}
else -> {
"Weather data download failed"
}
}
Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage")
Text(text = errorMessage,
style = TextStyle(
fontSize = TextUnit(16f, TextUnitType.Sp),
textAlign = TextAlign.Center,
color = ColorProvider(Color.Black, Color.White)
)
)
}
}

View File

@ -27,4 +27,6 @@
<string name="headwind_speed_description">Current headwind speed</string>
<string name="temperature">Temperature</string>
<string name="temperature_description">Current temperature in configured unit</string>
<string name="userwind_speed_description">Current headwind or wind speed based on user setting</string>
<string name="userwind_speed">Set wind speed</string>
</resources>

View File

@ -15,7 +15,7 @@
description="@string/tailwind_and_speed_description"
displayName="@string/tailwind_and_speed"
graphical="true"
icon="@drawable/ic_launcher"
icon="@drawable/wind"
typeId="tailwind-and-ride-speed" />
<DataType
@ -39,6 +39,13 @@
icon="@drawable/wind"
typeId="headwindSpeed" />
<DataType
description="@string/userwind_speed_description"
displayName="@string/userwind_speed"
graphical="false"
icon="@drawable/wind"
typeId="userwindSpeed" />
<DataType
description="@string/relativeHumidity_description"
displayName="@string/relativeHumidity"