Merge pull request 'cherry picks for Zepp OS weather (#5653)' (#6260)

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6260
This commit is contained in:
José Rebelo
2026-06-13 16:41:23 +02:00
2 changed files with 117 additions and 28 deletions
@@ -21,7 +21,9 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
@@ -67,7 +69,18 @@ public class ZeppOsWeatherService extends AbstractZeppOsService {
// When we have a weather update, set the default location to that location on the band.
// TODO: Support for multiple weather locations
final String locationKey = "1.234,-5.678,xiaomi_accu:" + System.currentTimeMillis(); // dummy
float lat = weatherSpec.getLatitude();
float lon = weatherSpec.getLongitude();
if (lat == 0f && lon == 0f) {
// Some weather providers don't populate WeatherSpec coords on broadcast.
// Fall back to user-configured location pref so the watch gets a valid
// default location and accepts subsequent v5 HTTP responses (issue #5653).
lat = GBApplication.getPrefs().getFloat("location_latitude", 0f);
lon = GBApplication.getPrefs().getFloat("location_longitude", 0f);
}
final String coordKey = String.format(Locale.ROOT, "%.4f,%.4f", lat, lon);
final long locationKeyId = ((long) coordKey.hashCode()) & 0xFFFFFFFFL;
final String locationKey = String.format(Locale.ROOT, "%.4f,%.4f,xiaomi_accu:%d", lat, lon, locationKeyId);
final String locationName = weatherSpec.getLocation();
try {
@@ -3,6 +3,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.servic
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec
import nodomain.freeyourgadget.gadgetbridge.model.weather.Weather.getWeatherSpec
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsWeatherHandler
@@ -18,6 +19,7 @@ import java.time.ZoneId
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
import java.util.Locale
import kotlin.math.roundToInt
@Suppress("unused")
@@ -61,22 +63,23 @@ object ZeppOsWeatherHandlerV5 {
val response = JsonObject()
for (dataset in datasets) {
val datasetObject = when (dataset) {
val datasetObject: Any = when (dataset) {
"place" -> createPlace(weatherSpec)
"hourlyWeather" -> createHourlyWeather(weatherSpec)
"hourlyAirQuality" -> createHourlyAirQuality(weatherSpec)
"dailyIndices" -> createDailyIndices(weatherSpec)
"dailyWeather" -> createDailyWeather(weatherSpec)
"dailyTide" -> createDailyTide(weatherSpec)
// TODO "dailyAirQuality" -> createDailyAirQuality(weatherSpec)
else -> null
"dailyAirQuality" -> createDailyAirQuality(weatherSpec)
else -> {
// Unknown dataset requested by newer firmware. Emit a shape stub instead of
// omitting the key — firmwares treat a missing key as "no data, ask user".
LOG.warn("Unknown weather dataset requested: {} — returning empty skeleton", dataset)
UnknownDataset(metadata = createMetadata(weatherSpec))
}
}
if (datasetObject != null) {
response.add(dataset, GSON.toJsonTree(datasetObject))
} else {
LOG.warn("Failed to compute dataset object for {}", dataset)
}
response.add(dataset, GSON.toJsonTree(datasetObject))
}
return GSON.toJson(response)
@@ -88,14 +91,32 @@ object ZeppOsWeatherHandlerV5 {
version = 1,
)
private fun createPlace(weatherSpec: WeatherSpec) = Place(
locationKey = "accu:123456",
longitude = weatherSpec.longitude.toString(),
latitude = weatherSpec.latitude.toString(),
affiliation = weatherSpec.location,
name = weatherSpec.location,
countryCode = null,
)
private fun createPlace(weatherSpec: WeatherSpec): Place {
val (lat, lon) = resolveCoordinates(weatherSpec)
// Stable per-location id so the watch can dedupe responses. Hash of rounded
// coords keeps it deterministic across requests for the same location.
val coordKey = String.format(Locale.ROOT, "%.4f,%.4f", lat, lon)
val locationKeyId = coordKey.hashCode().toLong() and 0xFFFFFFFFL
return Place(
locationKey = "accu:$locationKeyId",
longitude = String.format(Locale.ROOT, "%.3f", lon),
latitude = String.format(Locale.ROOT, "%.3f", lat),
affiliation = weatherSpec.location,
name = weatherSpec.location,
countryCode = null,
)
}
// Some weather providers don't populate WeatherSpec.latitude/longitude on broadcast.
// Fall back to the user-configured location pref so the watch gets a non-zero place,
// which it requires to accept the response as authoritative (issue #5653).
private fun resolveCoordinates(weatherSpec: WeatherSpec): Pair<Float, Float> {
if (weatherSpec.latitude != 0f || weatherSpec.longitude != 0f) {
return weatherSpec.latitude to weatherSpec.longitude
}
val prefs = GBApplication.getPrefs()
return prefs.getFloat("location_latitude", 0f) to prefs.getFloat("location_longitude", 0f)
}
private fun createHourlyWeather(weatherSpec: WeatherSpec): HourlyWeather {
return HourlyWeather(
@@ -120,13 +141,14 @@ object ZeppOsWeatherHandlerV5 {
)
}
private fun createHourlyAirQuality(weatherSpec: WeatherSpec): Any {
// TODO WeatherSpec does not support hourly air quality
return EmptyResponse()
//return HourlyAirQuality(
// metadata = createMetadata(weatherSpec),
// hours = emptyList()
//)
private fun createHourlyAirQuality(weatherSpec: WeatherSpec): HourlyAirQuality {
// TODO WeatherSpec does not yet carry hourly air-quality samples. Return the
// metadata + empty hours array so the firmware sees the expected shape and
// does not flag the widget as "no companion data".
return HourlyAirQuality(
metadata = createMetadata(weatherSpec),
hours = emptyList()
)
}
private fun createDailyIndices(weatherSpec: WeatherSpec): DailyIndices {
@@ -221,9 +243,40 @@ object ZeppOsWeatherHandlerV5 {
)
}
private fun createDailyTide(weatherSpec: WeatherSpec) = EmptyResponse(
// We do not support tide data yet
)
private fun createDailyTide(weatherSpec: WeatherSpec): DailyTide {
// TODO We do not source real tide data. Emit a populated shape stub
// (metadata + empty days array) so the firmware accepts the response.
return DailyTide(
metadata = createMetadata(weatherSpec),
days = emptyList()
)
}
private fun createDailyAirQuality(weatherSpec: WeatherSpec): DailyAirQuality {
// TODO WeatherSpec does not yet carry per-day air-quality samples. We emit
// a 7-day skeleton with placeholder AQI 50 ("good") so the Bip 6 widget has
// the shape it expects. Real data ingestion is future work — once
// WeatherSpec.Daily exposes pollutant fields, populate from there.
val days = (listOf(weatherSpec.todayAsDaily()) + weatherSpec.forecasts)
return DailyAirQuality(
metadata = createMetadata(weatherSpec),
days = days.mapIndexed { i, _ ->
val dayTimestamp = weatherSpec.timestamp * 1000L + i * 86400_000L
DailyAirQualityDay(
forecastStart = toOffsetDateTime(DateTimeUtils.dayStart(Date(dayTimestamp))),
forecastEnd = toOffsetDateTime(DateTimeUtils.dayEnd(Date(dayTimestamp))),
aqi = "50",
aqiLevel = "1",
co = "0",
no2 = "0",
o3 = "0",
pm10 = "0",
pm25 = "0",
so2 = "0",
)
}
)
}
private fun getMoonPhaseString(moonPhase: Int): String = when (moonPhase) {
// TODO only seen waxingCrescent
@@ -359,5 +412,28 @@ object ZeppOsWeatherHandlerV5 {
val days: List<DailyTideDay>,
)
class EmptyResponse
data class DailyAirQualityDay(
val forecastStart: OffsetDateTime,
val forecastEnd: OffsetDateTime,
val aqi: String,
val aqiLevel: String,
val co: String,
val no2: String,
val o3: String,
val pm10: String,
val pm25: String,
val so2: String,
)
data class DailyAirQuality(
val metadata: Metadata,
val days: List<DailyAirQualityDay>,
)
// Fallback shape for unknown dataset names — gives the firmware the metadata
// it expects plus an empty items array so the key is never omitted.
data class UnknownDataset(
val metadata: Metadata,
val items: List<Nothing> = emptyList(),
)
}