Add Breezy Weather integration

This commit is contained in:
MM20
2025-04-28 20:58:59 +02:00
parent 4e2fbf965f
commit 5e59bf171a
14 changed files with 645 additions and 66 deletions
@@ -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)
}
}
}