Add Breezy Weather integration
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application>
|
||||
<receiver android:name=".breezy.BreezyWeatherReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.mm20.launcher2.weather
|
||||
|
||||
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
|
||||
import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
|
||||
import de.mm20.launcher2.weather.here.HereProvider
|
||||
import de.mm20.launcher2.weather.metno.MetNoProvider
|
||||
@@ -17,6 +18,7 @@ val weatherModule = module {
|
||||
MetNoProvider.Id -> MetNoProvider(androidContext(), get())
|
||||
HereProvider.Id -> HereProvider(androidContext())
|
||||
BrightSkyProvider.Id -> BrightSkyProvider(androidContext())
|
||||
BreezyWeatherProvider.Id -> BreezyWeatherProvider(androidContext())
|
||||
else -> PluginWeatherProvider(androidContext(), providerId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.mm20.launcher2.plugin.PluginType
|
||||
import de.mm20.launcher2.preferences.LatLon
|
||||
import de.mm20.launcher2.preferences.weather.WeatherLocation
|
||||
import de.mm20.launcher2.preferences.weather.WeatherSettings
|
||||
import de.mm20.launcher2.weather.breezy.BreezyWeatherProvider
|
||||
import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
|
||||
import de.mm20.launcher2.weather.here.HereProvider
|
||||
import de.mm20.launcher2.weather.metno.MetNoProvider
|
||||
@@ -24,7 +25,9 @@ import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
interface WeatherRepository {
|
||||
fun getActiveProvider(): Flow<WeatherProviderInfo?>
|
||||
@@ -71,13 +74,6 @@ internal class WeatherRepositoryImpl(
|
||||
}
|
||||
|
||||
init {
|
||||
val weatherRequest =
|
||||
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMinutes(60))
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"weather",
|
||||
ExistingPeriodicWorkPolicy.UPDATE, weatherRequest
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
hasLocationPermission.collectLatest {
|
||||
@@ -86,6 +82,14 @@ internal class WeatherRepositoryImpl(
|
||||
}
|
||||
scope.launch {
|
||||
settings.collectLatest {
|
||||
val provider = WeatherProvider.getInstance(it.provider)
|
||||
val weatherRequest =
|
||||
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMillis(provider.getUpdateInterval()))
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"weather",
|
||||
ExistingPeriodicWorkPolicy.UPDATE, weatherRequest
|
||||
)
|
||||
requestUpdate()
|
||||
}
|
||||
}
|
||||
@@ -188,6 +192,15 @@ internal class WeatherRepositoryImpl(
|
||||
)
|
||||
)
|
||||
}
|
||||
if (BreezyWeatherProvider.isAvailable(context)) {
|
||||
providers.add(
|
||||
WeatherProviderInfo(
|
||||
BreezyWeatherProvider.Id,
|
||||
context.getString(R.string.provider_breezy),
|
||||
managedLocation = true
|
||||
)
|
||||
)
|
||||
}
|
||||
val pluginProviders = pluginRepository.findMany(type = PluginType.Weather, enabled = true)
|
||||
return pluginProviders.map {
|
||||
providers + it.mapNotNull {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.mm20.launcher2.weather.breezy
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class BreezyWeatherData(
|
||||
val timestamp: Long? = null,
|
||||
val location: String? = null,
|
||||
val currentTemp: Double? = null,
|
||||
/**
|
||||
* According to the spec this can be any OWM weather code (see https://openweathermap.org/weather-conditions),
|
||||
* but in reality, only the following codes are ever used:
|
||||
* 800, 801, 803, 500, 600, 771, 741, 751, 611, 511, 210, 211, 3200
|
||||
* (see https://github.com/breezy-weather/breezy-weather/blob/main/app/src/main/java/org/breezyweather/sources/gadgetbridge/GadgetbridgeService.kt#L37)
|
||||
*/
|
||||
val currentConditionCode: Int? = null,
|
||||
val currentCondition: String? = null,
|
||||
val currentHumidity: Int? = null,
|
||||
val todayMaxTemp: Int? = null,
|
||||
val todayMinTemp: Int? = null,
|
||||
val windSpeed: Float? = null,
|
||||
val windDirection: Int? = null,
|
||||
val uvIndex: Float? = null,
|
||||
val precipProbability: Int? = null,
|
||||
val dewPoint: Int? = null,
|
||||
val pressure: Float? = null,
|
||||
val cloudCover: Int? = null,
|
||||
val visibility: Float? = null,
|
||||
val sunRise: Int? = null,
|
||||
val sunSet: Int? = null,
|
||||
val moonRise: Int? = null,
|
||||
val moonSet: Int? = null,
|
||||
val moonPhase: Int? = null,
|
||||
val feelsLikeTemp: Int? = null,
|
||||
val forecasts: List<DailyForecast>? = null,
|
||||
val hourly: List<HourlyForecast>? = null,
|
||||
val airQuality: AirQuality? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class AirQuality(
|
||||
val aqi: Int? = null,
|
||||
val co: Float? = null,
|
||||
val no2: Float? = null,
|
||||
val o3: Float? = null,
|
||||
val pm10: Float? = null,
|
||||
val pm25: Float? = null,
|
||||
val so2: Float? = null,
|
||||
val coAqi: Int? = null,
|
||||
val no2Aqi: Int? = null,
|
||||
val o3Aqi: Int? = null,
|
||||
val pm10Aqi: Int? = null,
|
||||
val pm25Aqi: Int? = null,
|
||||
val so2Aqi: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DailyForecast(
|
||||
val minTemp: Int? = null,
|
||||
val maxTemp: Int? = null,
|
||||
val conditionCode: Int? = null,
|
||||
val humidity: Int? = null,
|
||||
val windSpeed: Float? = null,
|
||||
val windDirection: Int? = null,
|
||||
val uvIndex: Float? = null,
|
||||
val precipProbability: Int? = null,
|
||||
val sunRise: Int? = null,
|
||||
val sunSet: Int? = null,
|
||||
val moonRise: Int? = null,
|
||||
val moonSet: Int? = null,
|
||||
val moonPhase: Int? = null,
|
||||
val airQuality: AirQuality? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HourlyForecast(
|
||||
val timestamp: Int? = null,
|
||||
val temp: Int? = null,
|
||||
val conditionCode: Int? = null,
|
||||
val humidity: Int? = null,
|
||||
val windSpeed: Float? = null,
|
||||
val windDirection: Int? = null,
|
||||
val uvIndex: Float? = null,
|
||||
val precipProbability: Int? = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package de.mm20.launcher2.weather.breezy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import de.mm20.launcher2.database.AppDatabase
|
||||
import de.mm20.launcher2.preferences.weather.WeatherLocation
|
||||
import de.mm20.launcher2.weather.Forecast
|
||||
import de.mm20.launcher2.weather.R
|
||||
import de.mm20.launcher2.weather.WeatherIcon
|
||||
import de.mm20.launcher2.weather.WeatherProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class BreezyWeatherProvider(
|
||||
private val context: Context,
|
||||
) : WeatherProvider, KoinComponent {
|
||||
private val database: AppDatabase by inject()
|
||||
|
||||
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
|
||||
// Noop implementation, because Breezy weather is handled in a special way
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun getWeatherData(
|
||||
lat: Double,
|
||||
lon: Double
|
||||
): List<Forecast>? {
|
||||
// Noop implementation, because Breezy weather is handled in a special way
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun findLocation(query: String): List<WeatherLocation> {
|
||||
// Noop implementation, because Breezy weather is handled in a special way
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun getUpdateInterval(): Long {
|
||||
// Updates are pushed, no need to pull
|
||||
return Long.MAX_VALUE
|
||||
}
|
||||
|
||||
internal suspend fun pushWeatherData(data: BreezyWeatherData) {
|
||||
val result = mutableListOf<Forecast>()
|
||||
|
||||
val lastUpdate = System.currentTimeMillis()
|
||||
|
||||
result += Forecast(
|
||||
timestamp = data.timestamp?.times(1000L) ?: return,
|
||||
temperature = data.currentTemp ?: return,
|
||||
icon = iconForId(data.currentConditionCode ?: return).id,
|
||||
condition = data.currentCondition ?: return,
|
||||
location = data.location ?: return,
|
||||
provider = "Breezy Weather",
|
||||
clouds = data.cloudCover,
|
||||
humidity = data.currentHumidity?.toDouble(),
|
||||
pressure = data.pressure?.toDouble(),
|
||||
windSpeed = data.windSpeed?.toDouble()?.div(3.6),
|
||||
precipProbability = data.precipProbability,
|
||||
windDirection = data.windDirection?.toDouble(),
|
||||
providerUrl = "de.mm20.launcher.plugin.breezyweather://-",
|
||||
night = isNight(
|
||||
data.timestamp.times(1000L),
|
||||
data.sunRise?.times(1000L),
|
||||
data.sunSet?.times(1000L)
|
||||
),
|
||||
updateTime = lastUpdate,
|
||||
)
|
||||
|
||||
val sunrises = buildList {
|
||||
if (data.sunRise != null) add(data.sunRise.times(1000L))
|
||||
if (data.forecasts != null) addAll(data.forecasts.mapNotNull { it.sunRise?.times(1000L) })
|
||||
}.sorted()
|
||||
|
||||
val sunsets = buildList {
|
||||
if (data.sunSet != null) add(data.sunSet.times(1000L))
|
||||
if (data.forecasts != null) addAll(data.forecasts.mapNotNull { it.sunSet?.times(1000L) })
|
||||
}.sorted()
|
||||
|
||||
|
||||
for (hourly in data.hourly ?: emptyList()) {
|
||||
val timestamp = hourly.timestamp?.times(1000L) ?: continue
|
||||
|
||||
val lastSunrise = sunrises.findLast { it < timestamp }
|
||||
val lastSunset = sunsets.findLast { it < timestamp }
|
||||
val nextSunrise = sunrises.find { it > timestamp }
|
||||
val nextSunset = sunsets.find { it > timestamp }
|
||||
|
||||
val isNight = when {
|
||||
lastSunrise != null && lastSunset != null -> lastSunrise < lastSunset
|
||||
nextSunrise != null && nextSunset != null -> nextSunrise < nextSunset
|
||||
lastSunset != null && lastSunrise == null -> true
|
||||
nextSunrise != null && nextSunset == null -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
result += Forecast(
|
||||
timestamp = timestamp,
|
||||
temperature = hourly.temp?.toDouble() ?: continue,
|
||||
icon = iconForId(hourly.conditionCode ?: continue).id,
|
||||
condition = textForId(hourly.conditionCode) ?: continue,
|
||||
location = data.location,
|
||||
provider = "Breezy Weather",
|
||||
humidity = hourly.humidity?.toDouble(),
|
||||
windSpeed = hourly.windSpeed?.toDouble()?.div(3.6),
|
||||
precipProbability = hourly.precipProbability,
|
||||
windDirection = hourly.windDirection?.toDouble(),
|
||||
updateTime = lastUpdate,
|
||||
providerUrl = "de.mm20.launcher.plugin.breezyweather://-",
|
||||
night = isNight
|
||||
)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
database.weatherDao()
|
||||
.replaceAll(result.map { it.toDatabaseEntity() })
|
||||
}
|
||||
}
|
||||
|
||||
private fun iconForId(id: Int): WeatherIcon {
|
||||
return when (id) {
|
||||
200, 201, in 230..232 -> WeatherIcon.ThunderstormWithRain
|
||||
202 -> WeatherIcon.ThunderstormWithRain
|
||||
210, 211 -> WeatherIcon.Thunderstorm
|
||||
212, 221 -> WeatherIcon.HeavyThunderstorm
|
||||
in 300..302, in 310..312 -> WeatherIcon.Drizzle
|
||||
313, 314, 321, in 500..504, 511, in 520..522, 531 -> WeatherIcon.Showers
|
||||
in 600..602 -> WeatherIcon.Snow
|
||||
611, 612, 615, 616, in 620..622 -> WeatherIcon.Sleet
|
||||
701, 711, 731, 741, 761, 762 -> WeatherIcon.Fog
|
||||
721 -> WeatherIcon.Haze
|
||||
771, 781, in 900..902, in 958..962 -> WeatherIcon.Storm
|
||||
800 -> WeatherIcon.Clear
|
||||
801 -> WeatherIcon.PartlyCloudy
|
||||
802 -> WeatherIcon.MostlyCloudy
|
||||
803 -> WeatherIcon.BrokenClouds
|
||||
804, 951 -> WeatherIcon.Cloudy
|
||||
903 -> WeatherIcon.Cold
|
||||
904 -> WeatherIcon.Hot
|
||||
905, in 952..957 -> WeatherIcon.Wind
|
||||
906 -> WeatherIcon.Hail
|
||||
else -> WeatherIcon.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
private fun textForId(id: Int): String? {
|
||||
val resId = when (id) {
|
||||
800 -> R.string.weather_condition_clearsky
|
||||
801 -> R.string.weather_condition_partlycloudy
|
||||
803 -> R.string.weather_condition_cloudy
|
||||
500 -> R.string.weather_condition_rain
|
||||
600 -> R.string.weather_condition_snow
|
||||
771 -> R.string.weather_condition_wind
|
||||
741 -> R.string.weather_condition_fog
|
||||
751 -> R.string.weather_condition_haze
|
||||
611 -> R.string.weather_condition_sleet
|
||||
511 -> R.string.weather_condition_hail
|
||||
210 -> R.string.weather_condition_thunder
|
||||
211 -> R.string.weather_condition_thunderstorm
|
||||
else -> R.string.weather_condition_unknown
|
||||
}
|
||||
return context.getString(resId)
|
||||
}
|
||||
|
||||
private fun isNight(timestamp: Long, sunrise: Long?, sunset: Long?): Boolean {
|
||||
return (sunrise != null && timestamp < sunrise) || (sunset != null && timestamp > sunset)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun isAvailable(context: Context): Boolean {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo("org.breezyweather", 0)
|
||||
return true
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const val Id = "breezy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.mm20.launcher2.weather.breezy
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.preferences.weather.WeatherSettings
|
||||
import de.mm20.launcher2.serialization.Json
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.SerializationException
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class BreezyWeatherReceiver: BroadcastReceiver(), KoinComponent {
|
||||
|
||||
private val settings: WeatherSettings by inject()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
scope.launch {
|
||||
val provider = settings.providerId.first()
|
||||
if (provider != BreezyWeatherProvider.Id) {
|
||||
return@launch
|
||||
}
|
||||
val weatherJson = intent.getStringExtra("WeatherJson")
|
||||
|
||||
if (weatherJson == null) {
|
||||
Log.e("BreezyWeatherPlugin", "Broadcast was received but WeatherJson was null")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val weatherData = try {
|
||||
Json.Lenient.decodeFromString<BreezyWeatherData>(weatherJson)
|
||||
} catch (e: SerializationException) {
|
||||
CrashReporter.logException(e)
|
||||
return@launch
|
||||
}
|
||||
|
||||
BreezyWeatherProvider(context).pushWeatherData(weatherData)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user