Compare commits

..

No commits in common. "master" and "1.5.3-beta-4" have entirely different histories.

35 changed files with 237 additions and 999 deletions

View File

@ -1,54 +0,0 @@
name: Comment on Fixed Issues/PRs on Release
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to run the workflow for'
required: false
default: ''
jobs:
comment-on-fixed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for all tags and branches
- name: Find closed issues/PRs and comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Use the input tag if provided, otherwise use the tag from the push event
if [ -n "${{ github.event.inputs.tag }}" ]; then
RELEASE_TAG="${{ github.event.inputs.tag }}"
else
RELEASE_TAG="${{ github.ref }}"
# Remove the 'refs/tags/' part to get the tag name
RELEASE_TAG="${RELEASE_TAG#refs/tags/}"
fi
# Get the previous tag. If there is no previous tag, this will be empty.
PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -v "$RELEASE_TAG" | head -n 1)
# Get the commit range
if [ -z "$PREVIOUS_TAG" ]; then
# If there is no previous tag, get all commits up to the current tag
COMMIT_RANGE="$RELEASE_TAG"
else
COMMIT_RANGE="$PREVIOUS_TAG..$RELEASE_TAG"
fi
# Find the commits in this release
COMMITS=$(git log "$COMMIT_RANGE" --pretty=format:"%B")
# Extract issues/PRs closed (simple regex, can be improved)
echo "$COMMITS" | grep -oE "#[0-9]+" | sort -u | while read ISSUE; do
ISSUE_NUMBER="${ISSUE//#/}"
COMMENT="This issue/pr has been fixed in release ${RELEASE_TAG} :tada:"
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
done
shell: bash

View File

@ -23,19 +23,17 @@ 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. - 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. - 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. The "circular" variant uses the same circular graphics as the headwind indicator instead. - Wind direction and speed (graphical, 1x1 field): Similar to the tailwind data field, but shows the absolute wind speed and gust speed 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. - 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.
- Headwind forecast (graphical, 2x1 field): Shows the forecasted headwind speed if a route is loaded.
- 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. - 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). - 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).
- Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride. - Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride.
- Resistance forces (graphical, 2x1 field): Shows a graphical representation of the different forces you have to overcome while riding, including gravity (actual gradient), rolling resistance (based on speed and weight), aerodynamic drag (based on speed) and wind resistance (based on headwind speed). The app reads your weight from your karoo user profile and uses rough estimates for CdA and Crr.
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired. - Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.
The app can use OpenMeteo or OpenWeatherMap as providers for live weather data. The app can use OpenMeteo or OpenWeatherMap as providers for live weather data.
- OpenMeteo is the default provider and does not require any configuration. Wind speed will be reported in km/h if your karoo is set to metric units or mph if set to imperial. - OpenMeteo is the default provider and does not require any configuration. Wind speed will be reported in km/h if your karoo is set to metric units or mph if set to imperial.
- OpenWeatherMap can provide more accurate data for some locations. Forecasts along the loaded route are not available using OpenWeatherMap. OpenWeatherMap is free for personal use, but you need to register at https://openweathermap.org/home/sign_in and obtain a one call API key (e. g. by subscribing to "One Call API 3.0" from the [pricing page](https://openweathermap.org/price)). You can enter your API key in the app settings. Please note that it can take a few hours before OpenWeatherMap enables the key. You can check if your key is enabled by entering it in the app settings and pressing "Test API Key". Wind speed will be reported in km/h if your Karoo is set to metric units and miles per hour if set to imperial. - OpenWeatherMap can provide more accurate data for some locations. Forecasts along the loaded route are not available using OpenWeatherMap. OpenWeatherMap is free for personal use, but you need to register at https://openweathermap.org/home/sign_in and obtain a one call API key (e. g. by subscribing to "One Call API 3.0" from the [pricing page](https://openweathermap.org/price)). You can enter your API key in the app settings. Please note that it can take a few hours before OpenWeatherMap enables the key. You can check if your key is enabled by entering it in the app settings and pressing "Test API Key". Wind speed will be reported in meters per second if your Karoo is set to metric units and miles per hour if set to imperial.
The app will automatically attempt to download weather data from the selected data provider once your device has acquired a GPS fix. Your location is rounded to approximately three kilometers to maintain privacy. The app will automatically attempt to download weather data from the selected data provider once your device has acquired a GPS fix. Your location is rounded to approximately three kilometers to maintain privacy.
New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest. New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest.

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 "* Open main extension menu when clicking on graphical headwind / tailwind datafield\n* Only update position if the estimated accuracy is within 500 meters\n* Add force distribution datafield\n* Only increase relative elevation gain when relative grade is positive", "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

@ -294,9 +294,9 @@ fun lerpWeather(
weatherCode = closestWeatherData.weatherCode, weatherCode = closestWeatherData.weatherCode,
isForecast = closestWeatherData.isForecast, isForecast = closestWeatherData.isForecast,
isNight = closestWeatherData.isNight, isNight = closestWeatherData.isNight,
uvi = start.uvi + (end.uvi - start.uvi) * factor
) )
} }
fun lerpWeatherTime( fun lerpWeatherTime(
weatherData: List<WeatherData>?, weatherData: List<WeatherData>?,
currentWeatherData: WeatherData currentWeatherData: WeatherData

View File

@ -2,6 +2,7 @@ package de.timklge.karooheadwind
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.ActiveRidePage import io.hammerhead.karooext.models.ActiveRidePage
import io.hammerhead.karooext.models.OnLocationChanged
import io.hammerhead.karooext.models.OnNavigationState import io.hammerhead.karooext.models.OnNavigationState
import io.hammerhead.karooext.models.OnStreamState import io.hammerhead.karooext.models.OnStreamState
import io.hammerhead.karooext.models.RideState import io.hammerhead.karooext.models.RideState
@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transform
fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> { fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> {
@ -26,6 +28,17 @@ fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> {
} }
} }
fun KarooSystemService.streamLocation(): Flow<OnLocationChanged> {
return callbackFlow {
val listenerId = addConsumer { event: OnLocationChanged ->
trySendBlocking(event)
}
awaitClose {
removeConsumer(listenerId)
}
}
}
fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> { fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
return callbackFlow { return callbackFlow {
val listenerId = addConsumer { event: OnNavigationState -> val listenerId = addConsumer { event: OnNavigationState ->

View File

@ -12,11 +12,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull 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
import kotlinx.coroutines.flow.mapNotNull
sealed class HeadingResponse { sealed class HeadingResponse {
data object NoGps: HeadingResponse() data object NoGps: HeadingResponse()
@ -108,9 +108,8 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinat
val lat = dataPoint.values[DataType.Field.LOC_LATITUDE] val lat = dataPoint.values[DataType.Field.LOC_LATITUDE]
val lng = dataPoint.values[DataType.Field.LOC_LONGITUDE] val lng = dataPoint.values[DataType.Field.LOC_LONGITUDE]
val orientation = dataPoint.values[DataType.Field.LOC_BEARING] val orientation = dataPoint.values[DataType.Field.LOC_BEARING]
val accuracy = dataPoint.values[DataType.Field.LOC_ACCURACY]
if (lat != null && lng != null && accuracy != null && accuracy < 500) { if (lat != null && lng != null) {
emit(GpsCoordinates(lat, lng, orientation)) emit(GpsCoordinates(lat, lng, orientation))
Log.i(KarooHeadwindExtension.TAG, "No last known position found, fetched initial GPS position") Log.i(KarooHeadwindExtension.TAG, "No last known position found, fetched initial GPS position")
@ -131,21 +130,9 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinat
} }
} }
val gpsFlow = streamDataFlow(DataType.Type.LOCATION).mapNotNull { it as? StreamState.Streaming } val gpsFlow = streamLocation()
.mapNotNull { dataPoint -> .filter { it.orientation != null }
val lat = dataPoint.dataPoint.values[DataType.Field.LOC_LATITUDE] .map { GpsCoordinates(it.lat, it.lng, it.orientation) }
val lng = dataPoint.dataPoint.values[DataType.Field.LOC_LONGITUDE]
val orientation = dataPoint.dataPoint.values[DataType.Field.LOC_BEARING]
val accuracy = dataPoint.dataPoint.values[DataType.Field.LOC_ACCURACY]
Log.i(KarooHeadwindExtension.TAG, "Received GPS update: lat=$lat, lng=$lng, accuracy=$accuracy, orientation=$orientation")
if (lat != null && lng != null && accuracy != null && accuracy < 500) {
GpsCoordinates(lat, lng, orientation)
} else {
null
}
}
val concatenatedFlow = concatenate(initialFlow, gpsFlow) val concatenatedFlow = concatenate(initialFlow, gpsFlow)

View File

@ -7,23 +7,19 @@ import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.HeadwindForecastDataType
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
import de.timklge.karooheadwind.datatypes.PrecipitationDataType import de.timklge.karooheadwind.datatypes.PrecipitationDataType
import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType
import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
import de.timklge.karooheadwind.datatypes.ResistanceForcesDataType
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType import de.timklge.karooheadwind.datatypes.TailwindAndRideSpeedDataType
import de.timklge.karooheadwind.datatypes.TemperatureDataType import de.timklge.karooheadwind.datatypes.TemperatureDataType
import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType import de.timklge.karooheadwind.datatypes.TemperatureForecastDataType
import de.timklge.karooheadwind.datatypes.UviDataType
import de.timklge.karooheadwind.datatypes.WeatherForecastDataType import de.timklge.karooheadwind.datatypes.WeatherForecastDataType
import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType
import de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataTypeCircle
import de.timklge.karooheadwind.datatypes.WindDirectionDataType 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
@ -47,6 +43,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retry 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 java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -68,7 +66,6 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
listOf( listOf(
HeadwindDirectionDataType(karooSystem, applicationContext), HeadwindDirectionDataType(karooSystem, applicationContext),
TailwindAndRideSpeedDataType(karooSystem, applicationContext), TailwindAndRideSpeedDataType(karooSystem, applicationContext),
WindDirectionAndSpeedDataTypeCircle(karooSystem, applicationContext),
WeatherForecastDataType(karooSystem), WeatherForecastDataType(karooSystem),
HeadwindSpeedDataType(karooSystem, applicationContext), HeadwindSpeedDataType(karooSystem, applicationContext),
RelativeHumidityDataType(karooSystem, applicationContext), RelativeHumidityDataType(karooSystem, applicationContext),
@ -83,13 +80,10 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
TemperatureForecastDataType(karooSystem), TemperatureForecastDataType(karooSystem),
PrecipitationForecastDataType(karooSystem), PrecipitationForecastDataType(karooSystem),
WindForecastDataType(karooSystem), WindForecastDataType(karooSystem),
HeadwindForecastDataType(karooSystem),
WindDirectionAndSpeedDataType(karooSystem, applicationContext), WindDirectionAndSpeedDataType(karooSystem, applicationContext),
RelativeGradeDataType(karooSystem, applicationContext), RelativeGradeDataType(karooSystem, applicationContext),
RelativeElevationGainDataType(karooSystem, applicationContext), RelativeElevationGainDataType(karooSystem, applicationContext),
TemperatureDataType(karooSystem, applicationContext), TemperatureDataType(karooSystem, applicationContext)
UviDataType(karooSystem, applicationContext),
ResistanceForcesDataType(karooSystem, applicationContext)
) )
} }

View File

@ -65,29 +65,27 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
abstract class ForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) { abstract class ForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
@Composable @Composable
abstract fun RenderWidget( abstract fun RenderWidget(arrowBitmap: Bitmap,
arrowBitmap: Bitmap, current: WeatherInterpretation,
current: WeatherInterpretation, windBearing: Int,
windBearing: Int, windSpeed: Int,
windSpeed: Int, windGusts: Int,
windGusts: Int, precipitation: Double,
precipitation: Double, precipitationProbability: Int?,
precipitationProbability: Int?, temperature: Int,
temperature: Int, temperatureUnit: TemperatureUnit,
temperatureUnit: TemperatureUnit, timeLabel: String,
timeLabel: String, dateLabel: String?,
dateLabel: String?, distance: Double?,
distance: Double?, isImperial: Boolean,
isImperial: Boolean, isNight: Boolean)
isNight: Boolean,
uvi: Double
)
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
@ -109,7 +107,6 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
val weatherData = (0..<12).map { val weatherData = (0..<12).map {
val forecastTime = timeAtFullHour + it * 60 * 60 val forecastTime = timeAtFullHour + it * 60 * 60
val forecastTemperature = 20.0 + (-20..20).random() val forecastTemperature = 20.0 + (-20..20).random()
val forecastUvi = 0.0 + (0..12).random().toDouble()
val forecastPrecipitation = 0.0 + (0..10).random() val forecastPrecipitation = 0.0 + (0..10).random()
val forecastPrecipitationProbability = (0..100).random() val forecastPrecipitationProbability = (0..100).random()
val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random() val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random()
@ -130,8 +127,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
windGusts = forecastWindGusts, windGusts = forecastWindGusts,
weatherCode = forecastWeatherCode, weatherCode = forecastWeatherCode,
isForecast = true, isForecast = true,
isNight = it < 2, isNight = it < 2
uvi = forecastUvi
) )
} }
@ -153,8 +149,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
windGusts = 10.0, windGusts = 10.0,
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(), weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
isForecast = false, isForecast = false,
isNight = false, isNight = false
uvi = 2.0
), ),
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour), coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
timezone = "UTC", timezone = "UTC",
@ -338,8 +333,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
dateLabel = if (hasNewDate) formattedDate else null, dateLabel = if (hasNewDate) formattedDate else null,
distance = null, distance = null,
isImperial = settingsAndProfile.isImperial, isImperial = settingsAndProfile.isImperial,
isNight = data.current.isNight, isNight = data.current.isNight
uvi = data.current.uvi
) )
previousDate = formattedDate previousDate = formattedDate
@ -365,8 +359,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ
dateLabel = if (hasNewDate) formattedDate else null, dateLabel = if (hasNewDate) formattedDate else null,
distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null, distance = if (settingsAndProfile.settings.showDistanceInForecast) distanceFromCurrent else null,
isImperial = settingsAndProfile.isImperial, isImperial = settingsAndProfile.isImperial,
isNight = weatherData?.isNight == true, isNight = weatherData?.isNight == true
uvi = weatherData?.uvi ?: 0.0
) )
previousDate = formattedDate previousDate = formattedDate

View File

@ -67,7 +67,24 @@ class HeadwindDirectionDataType(
val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff
var returnValue = 0.0 var returnValue = 0.0
if (value != null && streamData.absoluteWindDirection != null && streamData.windSpeed != null) { 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 {
var windDirection = value var windDirection = value
if (windDirection < 0) windDirection += 360 if (windDirection < 0) windDirection += 360
@ -206,3 +223,14 @@ 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
}
}

View File

@ -67,7 +67,7 @@ fun HeadwindDirection(
val baseModifier = GlanceModifier.fillMaxSize().padding(5.dp).background(dayColor, nightColor).cornerRadius(10.dp) val baseModifier = GlanceModifier.fillMaxSize().padding(5.dp).background(dayColor, nightColor).cornerRadius(10.dp)
Box( Box(
modifier = if (!preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier, modifier = baseModifier, // TODO if (!preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier,
contentAlignment = Alignment( contentAlignment = Alignment(
vertical = Alignment.Vertical.CenterVertically, vertical = Alignment.Vertical.CenterVertically,
horizontal = Alignment.Horizontal.CenterHorizontally, horizontal = Alignment.Horizontal.CenterHorizontally,

View File

@ -1,148 +0,0 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.ContextCompat
import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.R
import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.lerpWeather
import de.timklge.karooheadwind.screens.LineGraphBuilder
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
fun interpolateWindLineColor(windSpeedInKmh: Double, night: Boolean, context: Context): androidx.compose.ui.graphics.Color {
val default = Color(ContextCompat.getColor(context, R.color.gray))
val green = Color(ContextCompat.getColor(context, R.color.green))
val red = Color(ContextCompat.getColor(context, R.color.red))
val orange = Color(ContextCompat.getColor(context, R.color.orange))
return when {
windSpeedInKmh <= -10 -> green
windSpeedInKmh >= 15 -> red
windSpeedInKmh in -10.0..0.0 -> interpolateColor(green, default, -10.0, 0.0, windSpeedInKmh)
windSpeedInKmh in 0.0..10.0 -> interpolateColor(default, orange, 0.0, 10.0, windSpeedInKmh)
else -> interpolateColor(orange, red, 10.0, 15.0, windSpeedInKmh)
}
}
class HeadwindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastDataType(karooSystem, "headwindForecast") {
override fun getLineData(
lineData: List<LineData>,
isImperial: Boolean,
upcomingRoute: UpcomingRoute?,
isPreview: Boolean,
context: Context
): LineGraphForecastData {
if (upcomingRoute == null && !isPreview){
return LineGraphForecastData.Error("No route loaded")
}
val windPoints = lineData.map { data ->
if (isImperial) { // Convert m/s to mph
data.weatherData.windSpeed * 2.23694 // Convert m/s to mph
} else { // Convert m/s to km/h
data.weatherData.windSpeed * 3.6 // Convert m/s to km/h
}
}
val headwindPoints = try {
(0..<HEADWIND_SAMPLE_COUNT).mapNotNull { i ->
val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
if (isPreview) {
// Use a sine wave for headwind preview speed
val headwindSpeed = 10f * kotlin.math.sin(i * Math.PI * 2 / HEADWIND_SAMPLE_COUNT).toFloat()
return@mapNotNull LineGraphBuilder.DataPoint(x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeed)
}
if (upcomingRoute == null) {
Log.e(KarooHeadwindExtension.TAG, "Upcoming route is null")
return@mapNotNull null
}
val beforeLineData = lineData.getOrNull(floor((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.lastOrNull()
if (beforeLineData?.weatherData == null || afterLineData?.weatherData == null || beforeLineData.distance == null
|| afterLineData.distance == null || beforeLineData == afterLineData) return@mapNotNull null
val dt = remap(t.toFloat(),
floor(lineData.size * t).toFloat() / lineData.size,
ceil(lineData.size * t).toFloat() / lineData.size,
0.0f, 1.0f
).toDouble()
val interpolatedWeather = lerpWeather(beforeLineData.weatherData, afterLineData.weatherData, dt)
val beforeDistanceAlongRoute = beforeLineData.distance
val afterDistanceAlongRoute = afterLineData.distance
val distanceAlongRoute = (beforeDistanceAlongRoute + (afterDistanceAlongRoute - beforeDistanceAlongRoute) * dt).coerceIn(0.0, upcomingRoute.routeLength)
val coordsAlongRoute = try {
TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS)
} 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 diff = signedAngleDifference(bearingAlongRoute, windBearing)
val headwindSpeed = cos( (diff + 180) * Math.PI / 180.0) * interpolatedWeather.windSpeed
val headwindSpeedInUserUnit = if (isImperial) {
headwindSpeed * 2.23694 // Convert m/s to mph
} else {
headwindSpeed * 3.6 // Convert m/s to km/h
}
LineGraphBuilder.DataPoint(
x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeedInUserUnit.toFloat()
)
}
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error calculating headwind points", e)
emptyList()
}
return LineGraphForecastData.LineData(buildSet {
if (headwindPoints.isNotEmpty()) {
add(LineGraphBuilder.Line(
dataPoints = headwindPoints,
color = android.graphics.Color.BLACK,
label = "Head", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false,
colorFunc = { headwindSpeed ->
val headwindSpeedInKmh = headwindSpeed * 3.6 // Convert m/s to km/h
interpolateWindLineColor(headwindSpeedInKmh, isNightMode(context), context).toArgb()
},
alpha = 255
))
}
})
}
companion object {
const val HEADWIND_SAMPLE_COUNT = 70
}
}

View File

@ -2,7 +2,6 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color
import android.util.Log import android.util.Log
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
@ -17,7 +16,6 @@ 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.R
import de.timklge.karooheadwind.UpcomingRoute import de.timklge.karooheadwind.UpcomingRoute
import de.timklge.karooheadwind.WeatherDataProvider import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.getHeadingFlow
@ -60,11 +58,6 @@ import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) { abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemService, typeId: String) : DataTypeImpl("karoo-headwind", typeId) {
sealed class LineGraphForecastData {
data class LineData(val data: Set<LineGraphBuilder.Line>) : LineGraphForecastData()
data class Error(val message: String) : LineGraphForecastData()
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews() private val glance = GlanceRemoteViews()
@ -82,7 +75,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
upcomingRoute: UpcomingRoute?, upcomingRoute: UpcomingRoute?,
isPreview: Boolean, isPreview: Boolean,
context: Context context: Context
): LineGraphForecastData ): Set<LineGraphBuilder.Line>
private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> = private fun previewFlow(settingsAndProfileStream: Flow<SettingsAndProfile>): Flow<StreamData> =
flow { flow {
@ -101,7 +94,6 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
val forecastWindSpeed = 0.0 + (0..10).random() val forecastWindSpeed = 0.0 + (0..10).random()
val forecastWindDirection = 0.0 + (0..360).random() val forecastWindDirection = 0.0 + (0..360).random()
val forecastWindGusts = 0.0 + (0..10).random() val forecastWindGusts = 0.0 + (0..10).random()
val forcastUvi = 0.0 + (0..12).random()
WeatherData( WeatherData(
time = forecastTime, time = forecastTime,
temperature = forecastTemperature, temperature = forecastTemperature,
@ -116,8 +108,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
windGusts = forecastWindGusts, windGusts = forecastWindGusts,
weatherCode = forecastWeatherCode, weatherCode = forecastWeatherCode,
isForecast = true, isForecast = true,
isNight = it < 2, isNight = it < 2
uvi = forcastUvi
) )
} }
@ -139,8 +130,7 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
windGusts = 10.0, windGusts = 10.0,
weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(), weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(),
isForecast = false, isForecast = false,
isNight = false, isNight = false
uvi = 2.0
), ),
coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour), coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour),
timezone = "UTC", timezone = "UTC",
@ -282,41 +272,30 @@ abstract class LineGraphForecastDataType(private val karooSystem: KarooSystemSer
context context
) )
when (pointData) { val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData) { x ->
is LineGraphForecastData.LineData -> { val startTime = data.firstOrNull()?.time
val bitmap = LineGraphBuilder(context).drawLineGraph(config.viewSize.first, config.viewSize.second, config.gridSize.first, config.gridSize.second, pointData.data) { x -> val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS)
val startTime = data.firstOrNull()?.time val timeLabel = getTimeFormatter(context).format(time?.atZone(ZoneId.systemDefault())?.toLocalTime())
val time = startTime?.plus(floor(x).toLong(), ChronoUnit.HOURS) val beforeData = data.getOrNull(floor(x).toInt().coerceAtLeast(0))
val timeLabel = getTimeFormatter(context).format(time?.atZone(ZoneId.systemDefault())?.toLocalTime()) val afterData = data.getOrNull(ceil(x).toInt().coerceAtMost(data.size - 1))
val beforeData = data.getOrNull(floor(x).toInt().coerceAtLeast(0))
val afterData = data.getOrNull(ceil(x).toInt().coerceAtMost(data.size - 1))
if (beforeData?.distance != null || afterData?.distance != null) { if (beforeData?.distance != null || afterData?.distance != null) {
val start = beforeData?.distance ?: 0.0f val start = beforeData?.distance ?: 0.0f
val end = (afterData?.distance ?: upcomingRoute?.routeLength?.toFloat()) ?: 0.0f val end = (afterData?.distance ?: upcomingRoute?.routeLength?.toFloat()) ?: 0.0f
val distance = start + (end - start) * (x - floor(x)) val distance = start + (end - start) * (x - floor(x))
val distanceLabel = if (settingsAndProfile.isImperial) { val distanceLabel = if (settingsAndProfile.isImperial) {
"${(distance * 0.000621371).toInt()}" "${(distance * 0.000621371).toInt()}"
} else { } else {
"${(distance / 1000).toInt()}" "${(distance / 1000).toInt()}"
}
return@drawLineGraph distanceLabel
} else {
timeLabel
}
}
Box(modifier = GlanceModifier.fillMaxSize()){
Image(ImageProvider(bitmap), "Forecast", modifier = GlanceModifier.fillMaxSize())
} }
return@drawLineGraph distanceLabel
} else {
timeLabel
} }
}
is LineGraphForecastData.Error -> { Box(modifier = GlanceModifier.fillMaxSize()){
emitter.onNext(ShowCustomStreamState(pointData.message, null)) Image(ImageProvider(bitmap), "Forecast", modifier = GlanceModifier.fillMaxSize())
Box(modifier = GlanceModifier.fillMaxSize()){
}
}
} }
} }

View File

@ -12,7 +12,7 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
upcomingRoute: UpcomingRoute?, upcomingRoute: UpcomingRoute?,
isPreview: Boolean, isPreview: Boolean,
context: Context context: Context
): LineGraphForecastData { ): 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
data.weatherData.precipitation * 0.0393701 // Convert mm to inches data.weatherData.precipitation * 0.0393701 // Convert mm to inches
@ -25,7 +25,7 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
(data.weatherData.precipitationProbability?.coerceAtMost(99.0)) ?: 0.0 // Max 99 % so that the label doesn't take up too much space (data.weatherData.precipitationProbability?.coerceAtMost(99.0)) ?: 0.0 // Max 99 % so that the label doesn't take up too much space
} }
return LineGraphForecastData.LineData(setOf( return setOf(
LineGraphBuilder.Line( LineGraphBuilder.Line(
dataPoints = precipitationPoints.mapIndexed { index, value -> dataPoints = precipitationPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat()) LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
@ -42,7 +42,7 @@ class PrecipitationForecastDataType(karooSystem: KarooSystemService) : LineGraph
label = "%", label = "%",
yAxis = LineGraphBuilder.YAxis.RIGHT yAxis = LineGraphBuilder.YAxis.RIGHT
) )
)) )
} }
} }

View File

@ -30,7 +30,7 @@ class RelativeElevationGainDataType(private val karooSystemService: KarooSystemS
val gradeDifferenceDueToWind = relativeGrade - actualGrade val gradeDifferenceDueToWind = relativeGrade - actualGrade
var intervalWindElevation = 0.0 var intervalWindElevation = 0.0
if (gradeDifferenceDueToWind > 0 && relativeGrade > 0) { if (gradeDifferenceDueToWind > 0) {
val distanceCovered = riderSpeed * deltaTime val distanceCovered = riderSpeed * deltaTime
intervalWindElevation = distanceCovered * gradeDifferenceDueToWind intervalWindElevation = distanceCovered * gradeDifferenceDueToWind
} }

View File

@ -4,9 +4,11 @@ import android.content.Context
import android.util.Log import android.util.Log
import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.WeatherDataProvider
import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamSettings
import de.timklge.karooheadwind.streamUserProfile import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle import de.timklge.karooheadwind.throttle
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
@ -17,6 +19,7 @@ import io.hammerhead.karooext.models.DataPoint
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.UserProfile
import io.hammerhead.karooext.models.ViewConfig import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -33,88 +36,12 @@ import kotlin.math.cos
class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") { class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") {
data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double) data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double)
data class ResistanceForces(
val airResistanceWithoutWind: Double,
val airResistanceWithWind: Double,
val rollingResistance: Double,
val gravitationalForce: Double
)
companion object { companion object {
// Default physical constants - adjust as needed // Default physical constants - adjust as needed
const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2) const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2)
const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3) const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3)
const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment. const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment.
const val DEFAULT_BIKE_WEIGHT = 9.0 // Default bike weight (kg). const val DEFAULT_BIKE_WEIGHT = 10.0 // Default bike weight (kg).
const val DEFAULT_CRR = 0.005 // Default coefficient of rolling resistance
/**
* Estimates the various resistance forces acting on a cyclist.
*
* @param actualGrade The current gradient of the road (unitless, e.g., 0.05 for 5%).
* @param riderSpeed The speed of the rider relative to the ground (m/s). Must be non-negative.
* @param windSpeed The speed of the wind relative to the ground (m/s). Must be non-negative.
* @param windDirectionDegrees The direction of the wind relative to the rider's direction
* of travel (degrees).
* 0 = direct headwind, 90 = crosswind right,
* 180 = direct tailwind, 270 = crosswind left.
* @param totalMass The combined mass of the rider and the bike (kg). Must be positive.
* @param cda The rider's coefficient of drag multiplied by their frontal area (m^2).
* Defaults to DEFAULT_CDA. Represents aerodynamic efficiency.
* @param crr The coefficient of rolling resistance. Defaults to DEFAULT_CRR.
* @param airDensity The density of the air (kg/m^3). Defaults to DEFAULT_AIR_DENSITY.
* @param g The acceleration due to gravity (m/s^2). Defaults to DEFAULT_GRAVITY.
* @return A [ResistanceForces] object containing the calculated forces, or null
* if input parameters are invalid.
*/
fun estimateResistanceForces(
actualGrade: Double,
riderSpeed: Double,
windSpeed: Double,
windDirectionDegrees: Double,
totalMass: Double,
cda: Double = DEFAULT_CDA,
crr: Double = DEFAULT_CRR,
airDensity: Double = DEFAULT_AIR_DENSITY,
g: Double = DEFAULT_GRAVITY
): ResistanceForces? {
// --- Input Validation ---
if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0 || crr < 0.0) {
Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters for force calculation.")
return null
}
// 1. Calculate wind component parallel to rider's direction
val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees))
// 2. Calculate effective air speed
val effectiveAirSpeed = riderSpeed + windComponentParallel
// 3. Calculate aerodynamic resistance factor
val aeroFactor = 0.5 * airDensity * cda
// 4. Calculate air resistance forces
// Drag Force = aeroFactor * speed^2 * sign(speed)
val airResistanceWithWind = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed)
val airResistanceWithoutWind = aeroFactor * riderSpeed * abs(riderSpeed)
// 5. Calculate gravitational force (force due to slope)
// Decomposing the gravitational force along the slope
val gravitationalForce = totalMass * g * actualGrade
// 6. Calculate rolling resistance force
// This is simplified; in reality, it's perpendicular to the road surface.
// F_rolling = Crr * N = Crr * m * g * cos(arctan(grade))
// For small angles, cos(arctan(grade)) is close to 1, so we approximate.
val rollingResistance = totalMass * g * crr
return ResistanceForces(
airResistanceWithoutWind = airResistanceWithoutWind,
airResistanceWithWind = airResistanceWithWind,
rollingResistance = rollingResistance,
gravitationalForce = gravitationalForce
)
}
/** /**
* Estimates the "relative grade" experienced by a cyclist. * Estimates the "relative grade" experienced by a cyclist.
@ -149,42 +76,42 @@ class RelativeGradeDataType(private val karooSystemService: KarooSystemService,
airDensity: Double = DEFAULT_AIR_DENSITY, airDensity: Double = DEFAULT_AIR_DENSITY,
g: Double = DEFAULT_GRAVITY, g: Double = DEFAULT_GRAVITY,
): Double { ): Double {
val forces = estimateResistanceForces( // --- Input Validation ---
actualGrade, if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0) {
riderSpeed, Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters. Mass/g must be positive; speeds, airDensity, Cda must be non-negative.")
windSpeed,
windDirectionDegrees,
totalMass,
cda,
DEFAULT_CRR,
airDensity,
g
)
if (forces == null) {
Log.w(KarooHeadwindExtension.TAG, "Could not calculate forces for relative grade.")
return Double.NaN return Double.NaN
} }
if (riderSpeed == 0.0 && windSpeed == 0.0) { if (riderSpeed == 0.0 && windSpeed == 0.0) {
// If no movement and no wind, relative grade is just the actual grade // If no movement and no wind, relative grade is just the actual grade
return actualGrade return actualGrade
} }
// The difference in force is purely from the wind. // 1. Calculate the component of wind speed parallel to the rider's direction of travel.
// This difference in force, when equated to a change in gravitational force, gives the change in grade. // cos(0 rad) = 1 (headwind), cos(PI rad) = -1 (tailwind)
// delta_F_air = F_air_with_wind - F_air_without_wind val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees))
// delta_F_air = m * g * delta_grade
// delta_grade = delta_F_air / (m * g) // 2. Calculate the effective air speed the rider experiences.
// relative_grade = actual_grade + delta_grade // This is rider speed + the parallel wind component.
val dragForceDifference = forces.airResistanceWithWind - forces.airResistanceWithoutWind val effectiveAirSpeed = riderSpeed + windComponentParallel
// 3. Calculate the aerodynamic resistance factor constant part.
val aeroFactor = 0.5 * airDensity * cda
// 4. Calculate the gravitational force component denominator.
val gravitationalFactor = totalMass * g val gravitationalFactor = totalMass * g
if (gravitationalFactor == 0.0) { // 5. Calculate the difference in the aerodynamic drag force term between
return actualGrade // Avoid division by zero // the current situation (with wind) and the hypothetical no-wind situation.
} // Drag Force = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed)
// We use speed * abs(speed) to ensure drag always opposes relative air motion.
val dragForceDifference = aeroFactor * ( (effectiveAirSpeed * abs(effectiveAirSpeed)) - (riderSpeed * abs(riderSpeed)) )
return actualGrade + (dragForceDifference / gravitationalFactor) // 6. Calculate the relative grade.
// It's the actual grade plus the equivalent grade change caused by the wind.
// Equivalent Grade Change = Drag Force Difference / Gravitational Force Component
val relativeGrade = actualGrade + (dragForceDifference / gravitationalFactor)
return relativeGrade
} }
suspend fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> { suspend fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> {

View File

@ -1,185 +0,0 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.util.Log
import androidx.compose.ui.unit.DpSize
import androidx.glance.ImageProvider
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.GlanceModifier
import androidx.glance.Image
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType.Companion.DEFAULT_BIKE_WEIGHT
import de.timklge.karooheadwind.getRelativeHeadingFlow
import de.timklge.karooheadwind.screens.BarChartBuilder
import de.timklge.karooheadwind.streamCurrentWeatherData
import de.timklge.karooheadwind.streamDataFlow
import de.timklge.karooheadwind.streamUserProfile
import de.timklge.karooheadwind.throttle
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.ShowCustomStreamState
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.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class ResistanceForcesDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(karooSystem, context, "forces"){
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windDirection
}
private fun previewFlow(): Flow<RelativeGradeDataType.ResistanceForces> {
return flow {
while (true) {
val withoutWind = (0..300).random().toDouble()
emit(RelativeGradeDataType.ResistanceForces(
withoutWind,
withoutWind + (0..200).random().toDouble(),
(0..50).random().toDouble(),
(-100..500).random().toDouble()
))
delay(1_000)
}
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
val flow = if (config.preview) {
previewFlow()
} else {
val relativeWindDirectionFlow = karooSystem.getRelativeHeadingFlow(context).filterIsInstance<HeadingResponse.Value>().map { it.diff + 180 }
val speedFlow = karooSystem.streamDataFlow(DataType.Type.SPEED).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue ?: 0.0 }
val actualGradeFlow = karooSystem.streamDataFlow(DataType.Type.ELEVATION_GRADE).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue }.filterNotNull().map { it / 100.0 } // Convert to decimal grade
val totalMassFlow = karooSystem.streamUserProfile().map {
if (it.weight in 30.0f..300.0f){
it.weight
} else {
Log.w(KarooHeadwindExtension.TAG, "Invalid rider weight ${it.weight} kg, defaulting to 70 kg")
70.0f // Default to 70 kg if weight is invalid
} + DEFAULT_BIKE_WEIGHT
}
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
val windSpeedFlow = context.streamCurrentWeatherData(karooSystem).filterNotNull().map { weatherData ->
weatherData.windSpeed
}
data class StreamValues(
val relativeWindDirection: Double,
val speed: Double,
val windSpeed: Double,
val actualGrade: Double,
val totalMass: Double
)
combine(relativeWindDirectionFlow, speedFlow, windSpeedFlow, actualGradeFlow, totalMassFlow) { windDirection, speed, windSpeed, actualGrade, totalMass ->
StreamValues(windDirection, speed, windSpeed, actualGrade, totalMass)
}.distinctUntilChanged().throttle(refreshRate).map { (windDirection, speed, windSpeed, actualGrade, totalMass) ->
val resistanceForces = RelativeGradeDataType.estimateResistanceForces(
actualGrade = actualGrade,
riderSpeed = speed,
windSpeed = windSpeed,
windDirectionDegrees = windDirection,
totalMass = totalMass
)
Log.d(KarooHeadwindExtension.TAG, "Resistance Forces: $resistanceForces")
resistanceForces
}
}
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
flow.throttle(refreshRate).collect { resistanceForces ->
if (resistanceForces != null) {
// Create bar chart data
val bars = listOf(
BarChartBuilder.BarData(
value = resistanceForces.airResistanceWithoutWind,
label = "Air",
smallLabel = "Air",
color = 0xFF4CAF50.toInt() // Green
),
BarChartBuilder.BarData(
value = resistanceForces.airResistanceWithWind - resistanceForces.airResistanceWithoutWind,
label = "Wind",
smallLabel = "Wind",
color = 0xFF2196F3.toInt() // Blue
),
BarChartBuilder.BarData(
value = resistanceForces.rollingResistance,
label = "Roll",
smallLabel = "R",
color = 0xFFFF9800.toInt() // Orange
),
BarChartBuilder.BarData(
value = resistanceForces.gravitationalForce,
label = "Gravity",
smallLabel = "G",
color = 0xFFF44336.toInt() // Red
)
)
// Draw bar chart
val bitmap = BarChartBuilder(context).drawBarChart(
width = config.viewSize.first,
height = config.viewSize.second,
bars = bars,
small = config.gridSize.first <= 30
)
// Use the correct ViewEmitter pattern with glance.compose
val glance = GlanceRemoteViews()
val result = glance.compose(context, DpSize.Unspecified) {
Box(modifier = GlanceModifier.fillMaxSize()) {
Image(
ImageProvider(bitmap),
"Resistance Forces Bar Chart",
modifier = GlanceModifier.fillMaxSize()
)
}
}
emitter.updateView(result.remoteViews)
} else {
// Display error message when no resistance forces data
emitter.onNext(ShowCustomStreamState("No resistance data", null))
}
}
}
emitter.setCancellable {
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -12,7 +12,7 @@ class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphFo
upcomingRoute: UpcomingRoute?, upcomingRoute: UpcomingRoute?,
isPreview: Boolean, isPreview: Boolean,
context: Context context: Context
): LineGraphForecastData { ): Set<LineGraphBuilder.Line> {
val linePoints = lineData.map { data -> val linePoints = lineData.map { data ->
if (isImperial) { if (isImperial) {
data.weatherData.temperature * 9 / 5 + 32 // Convert Celsius to Fahrenheit data.weatherData.temperature * 9 / 5 + 32 // Convert Celsius to Fahrenheit
@ -21,7 +21,7 @@ class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphFo
} }
} }
return LineGraphForecastData.LineData(setOf( return setOf(
LineGraphBuilder.Line( LineGraphBuilder.Line(
dataPoints = linePoints.mapIndexed { index, value -> dataPoints = linePoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat()) LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
@ -29,7 +29,7 @@ class TemperatureForecastDataType(karooSystem: KarooSystemService) : LineGraphFo
color = android.graphics.Color.RED, color = android.graphics.Color.RED,
label = if (!isImperial) "°C" else "°F", label = if (!isImperial) "°C" else "°F",
) )
)) )
} }
} }

View File

@ -1,12 +0,0 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.UserProfile
class UviDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "uvi"){
override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.uvi
}
}

View File

@ -22,10 +22,6 @@ import androidx.glance.text.TextStyle
import de.timklge.karooheadwind.HeadingResponse 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.streamSettings
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.HardwareType
import kotlinx.coroutines.flow.first
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult {
@ -80,14 +76,3 @@ 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
}
}

View File

@ -23,7 +23,6 @@ class WeatherForecastDataType(karooSystem: KarooSystemService) : ForecastDataTyp
distance: Double?, distance: Double?,
isImperial: Boolean, isImperial: Boolean,
isNight: Boolean, isNight: Boolean,
uvi: Double,
) { ) {
Weather( Weather(
arrowBitmap = arrowBitmap, arrowBitmap = arrowBitmap,

View File

@ -1,145 +0,0 @@
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<UserProfile>): Flow<de.timklge.karooheadwind.datatypes.WindDirectionAndSpeedDataType.StreamData> {
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()
}
}
}

View File

@ -29,7 +29,7 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
upcomingRoute: UpcomingRoute?, upcomingRoute: UpcomingRoute?,
isPreview: Boolean, isPreview: Boolean,
context: Context context: Context
): LineGraphForecastData { ): 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
data.weatherData.windSpeed * 2.23694 // Convert m/s to mph data.weatherData.windSpeed * 2.23694 // Convert m/s to mph
@ -46,7 +46,79 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
} }
} }
return LineGraphForecastData.LineData(buildSet { val headwindPoints = try {
if (upcomingRoute != null || isPreview){
(0..<HEADWIND_SAMPLE_COUNT).mapNotNull { i ->
val t = i / HEADWIND_SAMPLE_COUNT.toDouble()
if (isPreview) {
// Use a sine wave for headwind preview speed
val headwindSpeed = 10f * kotlin.math.sin(i * Math.PI * 2 / HEADWIND_SAMPLE_COUNT).toFloat()
val headwindSpeedInKmh = headwindSpeed * 3.6 // Convert m/s to km/h
return@mapNotNull LineGraphBuilder.DataPoint(x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeed)
}
if (upcomingRoute == null) return@mapNotNull null
val beforeLineData = lineData.getOrNull(floor((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.firstOrNull()
val afterLineData = lineData.getOrNull(ceil((lineData.size) * t).toInt().coerceAtLeast(0)) ?: lineData.lastOrNull()
if (beforeLineData?.weatherData == null || afterLineData?.weatherData == null || beforeLineData.distance == null
|| afterLineData.distance == null || beforeLineData == afterLineData) return@mapNotNull null
val dt = remap(t.toFloat(),
floor(lineData.size * t).toFloat() / lineData.size,
ceil(lineData.size * t).toFloat() / lineData.size,
0.0f, 1.0f
).toDouble()
val interpolatedWeather = lerpWeather(beforeLineData.weatherData, afterLineData.weatherData, dt)
val beforeDistanceAlongRoute = beforeLineData.distance
val afterDistanceAlongRoute = afterLineData.distance
val distanceAlongRoute = (beforeDistanceAlongRoute + (afterDistanceAlongRoute - beforeDistanceAlongRoute) * dt).coerceIn(0.0, upcomingRoute.routeLength)
val coordsAlongRoute = try {
TurfMeasurement.along(upcomingRoute.routePolyline, distanceAlongRoute, TurfConstants.UNIT_METERS)
} 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 diff = signedAngleDifference(bearingAlongRoute, windBearing)
val headwindSpeed = cos( (diff + 180) * Math.PI / 180.0) * interpolatedWeather.windSpeed
val headwindSpeedInUserUnit = if (isImperial) {
headwindSpeed * 2.23694 // Convert m/s to mph
} else {
headwindSpeed * 3.6 // Convert m/s to km/h
}
LineGraphBuilder.DataPoint(
x = i.toFloat() * (windPoints.size / HEADWIND_SAMPLE_COUNT.toFloat()),
y = headwindSpeedInUserUnit.toFloat()
)
}
} else {
emptyList()
}
} catch(e: Exception) {
Log.e(KarooHeadwindExtension.TAG, "Error calculating headwind points", e)
emptyList()
}
return buildSet {
add(LineGraphBuilder.Line( add(LineGraphBuilder.Line(
dataPoints = gustPoints.mapIndexed { index, value -> dataPoints = gustPoints.mapIndexed { index, value ->
LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat()) LineGraphBuilder.DataPoint(index.toFloat(), value.toFloat())
@ -62,6 +134,25 @@ class WindForecastDataType(karooSystem: KarooSystemService) : LineGraphForecastD
color = Color.GRAY, color = Color.GRAY,
label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph", label = "Wind" // if (!isImperial) "Wind km/h" else "Wind mph",
)) ))
})
if (headwindPoints.isNotEmpty()) {
add(LineGraphBuilder.Line(
dataPoints = headwindPoints,
color = if (isNightMode(context)) Color.WHITE else Color.BLACK,
label = "Head", // if (!isImperial) "Headwind km/h" else "Headwind mph",
drawCircles = false,
colorFunc = { headwindSpeed ->
val headwindSpeedInKmh = headwindSpeed * 3.6 // Convert m/s to km/h
interpolateWindColor(headwindSpeedInKmh, isNightMode(context), context).toArgb()
},
alpha = 255
))
}
}
} }
companion object {
const val HEADWIND_SAMPLE_COUNT = 50
}
} }

View File

@ -3,15 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){ class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){
override fun getValue(data: WeatherData, userProfile: UserProfile): Double { override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windGusts return data.windGusts
} }
override fun getFormatDataType(): String {
return DataType.Type.SPEED
}
} }

View File

@ -3,15 +3,10 @@ package de.timklge.karooheadwind.datatypes
import android.content.Context import android.content.Context
import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.weatherprovider.WeatherData
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.UserProfile import io.hammerhead.karooext.models.UserProfile
class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){ class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){
override fun getValue(data: WeatherData, userProfile: UserProfile): Double { override fun getValue(data: WeatherData, userProfile: UserProfile): Double {
return data.windSpeed return data.windSpeed
} }
override fun getFormatDataType(): String {
return DataType.Type.SPEED
}
} }

View File

@ -1,154 +0,0 @@
package de.timklge.karooheadwind.screens
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import androidx.annotation.ColorInt
import androidx.core.graphics.createBitmap
import kotlin.math.abs
class BarChartBuilder(val context: Context) {
data class BarData(
val value: Double,
val label: String,
val smallLabel: String,
@ColorInt val color: Int
)
fun drawBarChart(
width: Int,
height: Int,
small: Boolean,
bars: List<BarData>
): Bitmap {
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val isNightMode = isNightMode(context)
val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE
val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK
canvas.drawColor(backgroundColor)
if (bars.isEmpty()) {
val emptyPaint = Paint().apply {
color = primaryTextColor
textSize = 30f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint)
return bitmap
}
val marginTop = 45f
val marginBottom = 45f
val marginLeft = 5f
val marginRight = 5f
// Find the maximum absolute value to determine scale
val maxValue = bars.maxOfOrNull { abs(it.value) } ?: 1.0
val minValue = bars.minOfOrNull { it.value } ?: 0.0
// Determine if we need to show negative values
val hasNegativeValues = minValue < 0
val chartWidth = width - marginLeft - marginRight
val chartHeight = height - marginTop - marginBottom
val chartLeft = marginLeft
val chartTop = marginTop
val chartBottom = if (hasNegativeValues) height - marginBottom else height - 5.0f
val zeroY = if (hasNegativeValues) {
chartTop + chartHeight * (maxValue / (maxValue - minValue)).toFloat()
} else {
chartBottom
}
// Calculate bar dimensions
val barSpacing = 10f
val totalSpacing = (bars.size - 1) * barSpacing
val barWidth = (chartWidth - totalSpacing) / bars.size
// Draw bars
val barPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
}
bars.forEachIndexed { index, bar ->
val barLeft = chartLeft + index * (barWidth + barSpacing)
val barRight = barLeft + barWidth
val barHeight = if (hasNegativeValues) {
(abs(bar.value) / (maxValue - minValue) * chartHeight).toFloat()
} else {
(bar.value / maxValue * chartHeight).toFloat()
}
val barTop = if (bar.value >= 0) {
zeroY - barHeight
} else {
zeroY
}
val barBottom = if (bar.value >= 0) {
zeroY
} else {
zeroY + barHeight
}
// Draw bar
barPaint.color = bar.color
val rect = RectF(barLeft, barTop, barRight, barBottom)
canvas.drawRect(rect, barPaint)
// Draw label where value used to be with increased font size
val labelPaint = Paint().apply {
color = primaryTextColor
textSize = 32f // Increased from 24f and 28f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
// Use smallLabel if small is true, otherwise use regular label
val labelToUse = if (small) bar.smallLabel else bar.label
val labelY = if (bar.value >= 0) {
barTop - 10f // Position above positive bars
} else {
barBottom + labelPaint.textSize + 10f // Position below negative bars
}
// Create semi-transparent background box for label
val backgroundPaint = Paint().apply {
color = if (isNightMode) Color.argb(200, 0, 0, 0) else Color.argb(200, 255, 255, 255)
style = Paint.Style.FILL
isAntiAlias = true
}
// Calculate text bounds for background box
val textBounds = android.graphics.Rect()
labelPaint.getTextBounds(labelToUse, 0, labelToUse.length, textBounds)
val padding = 8f
val boxLeft = barLeft + barWidth / 2f - textBounds.width() / 2f - padding
val boxRight = barLeft + barWidth / 2f + textBounds.width() / 2f + padding
val boxTop = labelY - textBounds.height() - padding / 2f
val boxBottom = labelY + padding / 2f
// Draw rounded rectangle background
val backgroundRect = RectF(boxLeft, boxTop, boxRight, boxBottom)
val cornerRadius = 6f
canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, backgroundPaint)
canvas.drawText(labelToUse, barLeft + barWidth / 2f, labelY, labelPaint)
}
return bitmap
}
}

View File

@ -17,7 +17,6 @@ data class WeatherData(
val windGusts: Double, val windGusts: Double,
val weatherCode: Int, val weatherCode: Int,
val isForecast: Boolean, val isForecast: Boolean,
val isNight: Boolean, val isNight: Boolean
val uvi: Double,
) )

View File

@ -18,7 +18,6 @@ data class OpenMeteoWeatherData(
@SerialName("wind_gusts_10m") val windGusts: Double, @SerialName("wind_gusts_10m") val windGusts: Double,
@SerialName("weather_code") val weatherCode: Int, @SerialName("weather_code") val weatherCode: Int,
@SerialName("is_day") val isDay: Int, @SerialName("is_day") val isDay: Int,
@SerialName("uv_index") val uvi: Double,
) { ) {
fun toWeatherData(): WeatherData = WeatherData( fun toWeatherData(): WeatherData = WeatherData(
temperature = temperature, temperature = temperature,
@ -34,7 +33,6 @@ data class OpenMeteoWeatherData(
time = time, time = time,
isForecast = false, isForecast = false,
isNight = isDay == 0, isNight = isDay == 0,
uvi = uvi
) )
} }

View File

@ -19,7 +19,6 @@ data class OpenMeteoWeatherForecastData(
@SerialName("pressure_msl") val sealevelPressure: List<Double>, @SerialName("pressure_msl") val sealevelPressure: List<Double>,
@SerialName("is_day") val isDay: List<Int>, @SerialName("is_day") val isDay: List<Int>,
@SerialName("relative_humidity_2m") val relativeHumidity: List<Int>, @SerialName("relative_humidity_2m") val relativeHumidity: List<Int>,
@SerialName("uv_index") val uvi: List<Double>,
) { ) {
fun toWeatherData(): List<WeatherData> { fun toWeatherData(): List<WeatherData> {
return time.mapIndexed { index, t -> return time.mapIndexed { index, t ->
@ -38,7 +37,6 @@ data class OpenMeteoWeatherForecastData(
surfacePressure = surfacePressure[index], surfacePressure = surfacePressure[index],
sealevelPressure = sealevelPressure[index], sealevelPressure = sealevelPressure[index],
relativeHumidity = relativeHumidity[index], relativeHumidity = relativeHumidity[index],
uvi = uvi[index]
) )
} }
} }

View File

@ -30,7 +30,7 @@ class OpenMeteoWeatherProvider : WeatherProvider {
// https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=is_day,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 // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current=is_day,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 lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) }
val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) } 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=is_day,surface_pressure,pressure_msl,uv_index,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=uv_index,temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m,is_day,surface_pressure,pressure_msl,relative_humidity_2m,cloud_cover&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=ms" val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}&current=is_day,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,is_day,surface_pressure,pressure_msl,relative_humidity_2m,cloud_cover&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=ms"
Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...") Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...")

View File

@ -20,8 +20,7 @@ data class OpenWeatherMapForecastData(
val pop: Double, val pop: Double,
val rain: Rain? = null, val rain: Rain? = null,
val snow: Snow? = null, val snow: Snow? = null,
val weather: List<Weather>, val weather: List<Weather>
val uvi: Double,
) { ) {
fun toWeatherData(currentWeatherData: OpenWeatherMapWeatherData): WeatherData { fun toWeatherData(currentWeatherData: OpenWeatherMapWeatherData): WeatherData {
val dtInstant = Instant.ofEpochSecond(dt) val dtInstant = Instant.ofEpochSecond(dt)
@ -33,7 +32,6 @@ data class OpenWeatherMapForecastData(
val sunsetTime = sunsetInstant.atZone(ZoneOffset.UTC).toLocalTime() val sunsetTime = sunsetInstant.atZone(ZoneOffset.UTC).toLocalTime()
return WeatherData( return WeatherData(
uvi = uvi,
temperature = temp, temperature = temp,
relativeHumidity = humidity, relativeHumidity = humidity,
precipitation = rain?.h1 ?: 0.0, precipitation = rain?.h1 ?: 0.0,

View File

@ -19,11 +19,9 @@ data class OpenWeatherMapWeatherData(
val wind_gust: Double? = null, val wind_gust: Double? = null,
val rain: Rain? = null, val rain: Rain? = null,
val snow: Snow? = null, val snow: Snow? = null,
val uvi: Double,
val weather: List<Weather>){ val weather: List<Weather>){
fun toWeatherData(): WeatherData = WeatherData( fun toWeatherData(): WeatherData = WeatherData(
uvi = uvi,
temperature = temp, temperature = temp,
relativeHumidity = humidity, relativeHumidity = humidity,
precipitation = rain?.h1 ?: 0.0, precipitation = rain?.h1 ?: 0.0,

View File

@ -5,7 +5,6 @@
<color name="white">#ffffff</color> <color name="white">#ffffff</color>
<color name="black">#000000</color> <color name="black">#000000</color>
<color name="gray">#808080</color>
<color name="green">#00ff00</color> <color name="green">#00ff00</color>
<color name="orange">#ff9930</color> <color name="orange">#ff9930</color>

View File

@ -10,11 +10,11 @@
<string name="cloudCover">Cloud cover</string> <string name="cloudCover">Cloud cover</string>
<string name="cloudCover_description">Current cloud cover in percent</string> <string name="cloudCover_description">Current cloud cover in percent</string>
<string name="windSpeed">Wind speed</string> <string name="windSpeed">Wind speed</string>
<string name="windSpeed_description">Current absolute wind speed</string> <string name="windSpeed_description">Current wind speed in configured unit</string>
<string name="windGusts">Wind gusts</string> <string name="windGusts">Wind gusts</string>
<string name="windGusts_description">Current wind gust speed in configured unit</string> <string name="windGusts_description">Current wind gust speed in configured unit</string>
<string name="windDirection">Absolute wind direction</string> <string name="windDirection">Absolute wind direction</string>
<string name="windDirection_description">Current absolute wind direction</string> <string name="windDirection_description">Current wind direction</string>
<string name="precipitation">Rainfall</string> <string name="precipitation">Rainfall</string>
<string name="precipitation_description">Current precipitation (rainfall / snowfall)</string> <string name="precipitation_description">Current precipitation (rainfall / snowfall)</string>
<string name="surfacePressure">Surface pressure</string> <string name="surfacePressure">Surface pressure</string>
@ -22,15 +22,11 @@
<string name="sealevelPressure">Sealevel pressure</string> <string name="sealevelPressure">Sealevel pressure</string>
<string name="sealevelPressure_description">Atmospheric pressure at sea level in configured unit</string> <string name="sealevelPressure_description">Atmospheric pressure at sea level in configured unit</string>
<string name="weather_forecast">Weather Forecast</string> <string name="weather_forecast">Weather Forecast</string>
<string name="uvi">UV Index</string>
<string name="uvi_description">Current UV Index at current location</string>
<string name="weather_forecast_description">Current hourly weather forecast</string> <string name="weather_forecast_description">Current hourly weather forecast</string>
<string name="temperature_forecast">Temperature Forecast</string> <string name="temperature_forecast">Temperature Forecast</string>
<string name="temperature_forecast_description">Current hourly temperature forecast</string> <string name="temperature_forecast_description">Current hourly temperature forecast</string>
<string name="wind_forecast">Wind Forecast</string> <string name="wind_forecast">Wind Forecast</string>
<string name="wind_forecast_description">Current hourly wind forecast</string> <string name="wind_forecast_description">Current hourly wind forecast</string>
<string name="headwind_forecast">Headwind Forecast</string>
<string name="headwind_forecast_description">Current hourly headwind forecast for loaded route</string>
<string name="precipitation_forecast">Precipitation Forecast</string> <string name="precipitation_forecast">Precipitation Forecast</string>
<string name="precipitation_forecast_description">Current hourly precipitation forecast</string> <string name="precipitation_forecast_description">Current hourly precipitation forecast</string>
<string name="temperature">Temperature</string> <string name="temperature">Temperature</string>
@ -43,10 +39,6 @@
<string name="relativeGrade_description">Perceived grade in percent</string> <string name="relativeGrade_description">Perceived grade in percent</string>
<string name="relativeElevationGain">Relative Elevation Gain</string> <string name="relativeElevationGain">Relative Elevation Gain</string>
<string name="relativeElevationGain_description">Perceived elevation gain in meters</string> <string name="relativeElevationGain_description">Perceived elevation gain in meters</string>
<string name="windDirectionAndSpeed">Wind direction, speed</string> <string name="windDirectionAndSpeed">Wind direction and speed</string>
<string name="windDirectionAndSpeed_description">Current wind direction and wind speed</string> <string name="windDirectionAndSpeed_description">Current wind direction and wind speed</string>
<string name="windDirectionAndSpeedCircle">Wind direction, speed (Circle)</string>
<string name="windDirectionAndSpeedCircle_description">Current wind direction and wind speed (Circle graphics)</string>
<string name="forces">Resistance forces</string>
<string name="forces_description">Current resistance forces (air, rolling, gradient)</string>
</resources> </resources>

View File

@ -22,17 +22,10 @@
<DataType <DataType
description="@string/windDirectionAndSpeed_description" description="@string/windDirectionAndSpeed_description"
displayName="@string/windDirectionAndSpeed" displayName="@string/windDirectionAndSpeed"
graphical="true" graphical="false"
icon="@drawable/wind" icon="@drawable/wind"
typeId="windDirectionAndSpeed" /> typeId="windDirectionAndSpeed" />
<DataType
description="@string/windDirectionAndSpeedCircle_description"
displayName="@string/windDirectionAndSpeedCircle"
graphical="true"
icon="@drawable/wind"
typeId="windDirectionAndSpeedCircle" />
<DataType <DataType
description="@string/weather_forecast_description" description="@string/weather_forecast_description"
displayName="@string/weather_forecast" displayName="@string/weather_forecast"
@ -61,13 +54,6 @@
icon="@drawable/wind" icon="@drawable/wind"
typeId="windForecast" /> typeId="windForecast" />
<DataType
description="@string/headwind_forecast_description"
displayName="@string/headwind_forecast"
graphical="true"
icon="@drawable/wind"
typeId="headwindForecast" />
<DataType <DataType
description="@string/headwind_speed_description" description="@string/headwind_speed_description"
displayName="@string/headwind_speed" displayName="@string/headwind_speed"
@ -138,13 +124,6 @@
icon="@drawable/thermometer" icon="@drawable/thermometer"
typeId="temperature" /> typeId="temperature" />
<DataType
description="@string/uvi_description"
displayName="@string/uvi"
graphical="false"
icon="@drawable/thermometer"
typeId="uvi" />
<DataType <DataType
description="@string/relativeGrade_description" description="@string/relativeGrade_description"
displayName="@string/relativeGrade" displayName="@string/relativeGrade"
@ -158,11 +137,4 @@
graphical="false" graphical="false"
icon="@drawable/wind" icon="@drawable/wind"
typeId="relativeElevationGain" /> typeId="relativeElevationGain" />
<DataType
description="@string/forces_description"
displayName="@string/forces"
graphical="true"
icon="@drawable/wind"
typeId="forces" />
</ExtensionInfo> </ExtensionInfo>

View File

@ -1,5 +1,3 @@
package de.timklge.karooheadwind.datatypes
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals