/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo, opcode This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services; import android.text.TextUtils; import androidx.annotation.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiWeatherConditions; import nodomain.freeyourgadget.gadgetbridge.model.Weather; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; public class XiaomiWeatherService extends AbstractXiaomiService { private static final Logger LOG = LoggerFactory.getLogger(XiaomiWeatherService.class); private static final int TEMPERATURE_SCALE_CELSIUS = 1; private static final int TEMPERATURE_SCALE_FAHRENHEIT = 2; public static final int COMMAND_TYPE = 10; private static final int CMD_SET_CURRENT_WEATHER = 0; private static final int CMD_UPDATE_DAILY_FORECAST = 1; private static final int CMD_UPDATE_HOURLY_FORECAST = 2; private static final int CMD_REQUEST_CONDITIONS_FOR_LOCATION = 3; private static final int CMD_GET_LOCATIONS = 5; private static final int CMD_SET_LOCATIONS = 6; private static final int CMD_ADD_LOCATION = 7; private static final int CMD_REMOVE_LOCATIONS = 8; private static final int CMD_GET_WEATHER_PREFS = 9; private static final int CMD_SET_WEATHER_PREFS = 10; private final Set cachedWeatherLocations = new HashSet<>(); public XiaomiWeatherService(final XiaomiSupport support) { super(support); } @Override public void initialize() { // since temperature unit is app-wide instead of device-specific, update device setting during init setMeasurementSystem(); // determine whether multiple weather locations are supported by device // device should respond with status 1 if unsupported, we will then send cached weather getSupport().sendCommand("get weather locations", COMMAND_TYPE, CMD_GET_LOCATIONS); } @Override public void handleCommand(final XiaomiProto.Command cmd) { if (cmd.hasStatus() && cmd.getStatus() != 0) { LOG.warn("Received Weather command {} with status code {}", cmd.getSubtype(), cmd.getStatus()); } switch (cmd.getSubtype()) { case CMD_UPDATE_DAILY_FORECAST: { if (!cmd.hasStatus()) { LOG.warn("Received unexpected response to daily forecast update"); return; } switch (cmd.getStatus()) { case 0: LOG.debug("Successfully sent daily forecast update"); break; case 1: LOG.warn("Daily forecasts not supported by device"); break; default: LOG.error("Unexpected status code {} in response to daily forecast update", cmd.getStatus()); break; } return; } case CMD_UPDATE_HOURLY_FORECAST: { if (!cmd.hasStatus()) { LOG.warn("Received unexpected response to hourly forecast update"); return; } switch (cmd.getStatus()) { case 0: LOG.debug("Successfully sent hourly forecast update"); break; case 1: LOG.warn("Hourly forecasts not supported by device"); break; default: LOG.error("Unexpected status code {} in response to hourly forecast update", cmd.getStatus()); break; } return; } case CMD_REQUEST_CONDITIONS_FOR_LOCATION: { onConditionRequestReceived(cmd); return; } case CMD_GET_LOCATIONS: { onWeatherLocationsReceived(cmd); return; } case CMD_SET_LOCATIONS: { if (!cmd.hasStatus()) { LOG.warn("Received unexpected response after setting locations"); return; } switch (cmd.getStatus()) { case 0: LOG.debug("Successfully updated location list"); break; case 1: LOG.warn("Device reported that setting the location list is not supported!"); break; default: LOG.error("Received status code {} for setting location list", cmd.getStatus()); break; } return; } case CMD_ADD_LOCATION: { if (!cmd.hasStatus()) { LOG.warn("Unexpected response to add location command"); return; } switch (cmd.getStatus()) { case 0: LOG.debug("Successfully added weather location"); break; case 1: LOG.debug("Adding single weather location not supported on this device"); break; case 3: LOG.debug("Failed to add single weather location; this location may have already been added"); break; default: LOG.warn("Unexpected status code {} in response to adding single weather location", cmd.getStatus()); break; } return; } case CMD_REMOVE_LOCATIONS: { if (!cmd.hasStatus()) { LOG.warn("Unexpected response to remove locations command"); return; } switch (cmd.getStatus()) { case 0: LOG.debug("Successfully removed weather locations"); break; case 1: LOG.debug("Removing weather locations not supported on this device"); break; default: LOG.warn("Unexpected status code {} in response to removing weather locations", cmd.getStatus()); break; } return; } case CMD_SET_WEATHER_PREFS: { if (!cmd.hasStatus()) { LOG.warn("Received unexpected response after setting weather preferences"); return; } switch (cmd.getStatus()) { case 0: LOG.debug("Successfully updated weather-related preferences"); break; case 1: LOG.warn("Weather-related preferences are not supported on this device"); break; default: LOG.debug("Unexpected status code {} received in response to weather prefs update request", cmd.getStatus()); break; } return; } } LOG.warn("Unhandled weather service command {}", cmd.getSubtype()); LOG.debug("Unhandled command content: {}", cmd); } @Override public boolean onSendConfiguration(final String config, final Prefs prefs) { // TODO add preference for warning notifications (if that has any effect at all) switch (config) { case SettingsActivity.PREF_MEASUREMENT_SYSTEM: setMeasurementSystem(); return true; } return false; } private static XiaomiProto.WeatherMetadata getWeatherMetaFromSpec(final WeatherSpec weatherSpec) { final String location = StringUtils.ensureNotNull(weatherSpec.location); return XiaomiProto.WeatherMetadata.newBuilder() .setPublicationTimestamp(unixTimestampToISOWithColons(weatherSpec.timestamp)) .setCityName("") .setLocationName(location) .setLocationKey(getLocationKey(location)) // FIXME: placeholder because key is not present in spec .setIsCurrentLocation(weatherSpec.isCurrentLocation == 1) .build(); } private static XiaomiProto.WeatherLocation getWeatherLocationFromSpec(final WeatherSpec weatherSpec) { return XiaomiProto.WeatherLocation.newBuilder() .setCode(getLocationKey(weatherSpec.location)) .setName(StringUtils.ensureNotNull(weatherSpec.location)) .build(); } private static String getLocationKey(final String locationName) { return String.format(Locale.ROOT, "accu:%d", Math.abs(StringUtils.ensureNotNull(locationName).hashCode()) % 1000000); } private static XiaomiProto.WeatherUnitValue buildUnitValue(final int value, final String unit) { return XiaomiProto.WeatherUnitValue.newBuilder() .setUnit(unit) .setValue(value) .build(); } private void addWeatherLocation(final XiaomiProto.WeatherLocation location) { LOG.debug("Adding weather location: code={}, name={}", location.getCode(), location.getName()); getSupport().sendCommand("add weather location", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_ADD_LOCATION) .setWeather(XiaomiProto.Weather.newBuilder() .setLocation(location)) .build()); } private void addWeatherLocationFromSpec(final WeatherSpec spec) { addWeatherLocation(getWeatherLocationFromSpec(spec)); } public void sendCurrentConditions(final WeatherSpec weatherSpec) { LOG.debug("Sending current weather conditions for {}", weatherSpec.location); XiaomiProto.Command command = XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_SET_CURRENT_WEATHER) .setWeather(XiaomiProto.Weather.newBuilder().setCurrent( XiaomiProto.WeatherCurrent.newBuilder() .setMetadata(getWeatherMetaFromSpec(weatherSpec)) .setWeatherCondition(XiaomiWeatherConditions.convertOwmConditionToXiaomi(weatherSpec.currentConditionCode)) .setTemperature(buildUnitValue(weatherSpec.currentTemp - 273, "℃")) .setHumidity(buildUnitValue(weatherSpec.currentHumidity, "%")) .setWind(buildUnitValue(weatherSpec.windSpeedAsBeaufort(), Integer.toString(weatherSpec.windDirection))) .setUv(buildUnitValue(Math.round(weatherSpec.uvIndex), "")) // This is sent as an sint but seems to be displayed with a decimal point .setAqi(buildUnitValue( weatherSpec.airQuality != null && weatherSpec.airQuality.aqi >= 0 ? weatherSpec.airQuality.aqi : 0, "Unknown" // some string like "Moderate" )) .setWarning(XiaomiProto.WeatherWarnings.newBuilder()) // TODO add warnings when they become available through spec .setPressure(weatherSpec.pressure * 100f) )) .build(); getSupport().sendCommand("set current weather", command); } public void sendDailyForecast(final WeatherSpec weatherSpec) { final XiaomiProto.ForecastEntries.Builder entryListBuilder = XiaomiProto.ForecastEntries.newBuilder(); final int daysToSend = Math.min(6, weatherSpec.forecasts.size()); // reconstruct first forecast element from current conditions, as the first forecast // is expected to apply to today { entryListBuilder.addEntry(XiaomiProto.ForecastEntry.newBuilder() .setAqi(buildUnitValue( weatherSpec.airQuality != null && weatherSpec.airQuality.aqi >= 0 ? weatherSpec.airQuality.aqi : 0, "Unknown" // TODO describe AQI level )) .setTemperatureRange(XiaomiProto.WeatherRange.newBuilder() .setFrom(weatherSpec.todayMinTemp - 273) .setTo(weatherSpec.todayMaxTemp - 273)) // FIXME: should preferable be replaced with a best and worst case condition whenever that becomes available .setConditionRange(XiaomiProto.WeatherRange.newBuilder() .setFrom(XiaomiWeatherConditions.convertOwmConditionToXiaomi(weatherSpec.currentConditionCode)) .setTo(XiaomiWeatherConditions.convertOwmConditionToXiaomi(weatherSpec.currentConditionCode))) .setTemperatureSymbol("℃") .setSunriseSunset(XiaomiProto.WeatherSunriseSunset.newBuilder() .setSunrise(weatherSpec.sunRise != 0 ? unixTimestampToISOWithColons(weatherSpec.sunRise) : "") .setSunset(weatherSpec.sunSet != 0 ? unixTimestampToISOWithColons(weatherSpec.sunSet) : ""))); } // loop over available forecast entries in weatherSpec for (WeatherSpec.Daily currentEntry : weatherSpec.forecasts.subList(0, daysToSend)) { entryListBuilder.addEntry(XiaomiProto.ForecastEntry.newBuilder() .setAqi(buildUnitValue( currentEntry.airQuality != null && currentEntry.airQuality.aqi >= 0 ? currentEntry.airQuality.aqi : 0, "Unknown" // TODO describe AQI level )) // FIXME should preferable be replaced with a best and worst case condition whenever that becomes available .setConditionRange(XiaomiProto.WeatherRange.newBuilder() .setFrom(XiaomiWeatherConditions.convertOwmConditionToXiaomi(currentEntry.conditionCode)) .setTo(XiaomiWeatherConditions.convertOwmConditionToXiaomi(currentEntry.conditionCode))) .setTemperatureRange(XiaomiProto.WeatherRange.newBuilder() .setTo(currentEntry.maxTemp - 273) .setFrom(currentEntry.minTemp - 273)) .setTemperatureSymbol("℃") .setSunriseSunset(XiaomiProto.WeatherSunriseSunset.newBuilder() .setSunrise(currentEntry.sunRise != 0 ? unixTimestampToISOWithColons(currentEntry.sunRise) : "") .setSunset(currentEntry.sunSet != 0 ? unixTimestampToISOWithColons(currentEntry.sunSet) : ""))); } LOG.debug("Sending daily forecast with {} days of info", entryListBuilder.getEntryCount()); XiaomiProto.Command command = XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_UPDATE_DAILY_FORECAST) .setWeather(XiaomiProto.Weather.newBuilder().setForecast( XiaomiProto.WeatherForecast.newBuilder() .setMetadata(getWeatherMetaFromSpec(weatherSpec)) .setEntries(entryListBuilder))) .build(); getSupport().sendCommand("set daily forecast", command); } public void sendHourlyForecast(final WeatherSpec weatherSpec) { final XiaomiProto.ForecastEntries.Builder entriesBuilder = XiaomiProto.ForecastEntries.newBuilder(); final int hoursToSend = Math.min(23, weatherSpec.hourly.size()); for (WeatherSpec.Hourly hourly : weatherSpec.hourly.subList(0, hoursToSend)) { entriesBuilder.addEntry(XiaomiProto.ForecastEntry.newBuilder() .setAqi(buildUnitValue(0, "Unknown")) // FIXME when available through spec .setTemperatureRange(XiaomiProto.WeatherRange.newBuilder() .setFrom(0) // not set, but required .setTo(hourly.temp - 273)) .setConditionRange(XiaomiProto.WeatherRange.newBuilder() .setFrom(0) // not set, but required .setTo(XiaomiWeatherConditions.convertOwmConditionToXiaomi(hourly.conditionCode))) .setTemperatureSymbol("℃") .setWind(buildUnitValue(hourly.windSpeedAsBeaufort(), Integer.toString(hourly.windDirection)))); } LOG.debug("Sending hourly forecast with {} hours of info", entriesBuilder.getEntryCount()); final XiaomiProto.Command command = XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_UPDATE_HOURLY_FORECAST) .setWeather(XiaomiProto.Weather.newBuilder() .setForecast(XiaomiProto.WeatherForecast.newBuilder() .setMetadata(getWeatherMetaFromSpec(weatherSpec)) .setEntries(entriesBuilder))) .build(); getSupport().sendCommand("update hourly forecast", command); } private boolean supportsMultipleWeatherLocations() { return getDevicePrefs().getBoolean(XiaomiPreferences.FEAT_MULTIPLE_WEATHER_LOCATIONS, false); } public void onSendWeather(@NonNull final List weatherSpecList) { if (supportsMultipleWeatherLocations()) { sendWeatherSpecList(weatherSpecList); } else { if (!weatherSpecList.isEmpty() && weatherSpecList.get(0) != null) { final WeatherSpec specToSend = weatherSpecList.get(0); addWeatherLocationFromSpec(specToSend); sendWeatherSpec(specToSend); } } } private void sendWeatherSpecList(@NonNull final List weatherSpecs) { // FIXME: the MB8P seems to support more locations than the original app allows, which is // undoubtedly also applicable to other devices List specsToSend = weatherSpecs.subList(0, Math.min(5, weatherSpecs.size())); List weatherLocations = new ArrayList<>(specsToSend.size()); LOG.debug("Updating weather for {} location(s): {}", specsToSend.size(), extractWeatherSpecLocations(specsToSend)); // find locations not present on device { for (final WeatherSpec spec : specsToSend) { final XiaomiProto.WeatherLocation location = getWeatherLocationFromSpec(spec); if (!cachedWeatherLocations.contains(location)) { addWeatherLocation(location); // assume adding location goes according to plan cachedWeatherLocations.add(location); } weatherLocations.add(location); } } // update order of locations list on device { getSupport().sendCommand("set weather locations order", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_SET_LOCATIONS) .setWeather(XiaomiProto.Weather.newBuilder() .setLocations(XiaomiProto.WeatherLocations.newBuilder() .addAllLocation(weatherLocations))) .build()); } for (WeatherSpec spec : specsToSend) { sendWeatherSpec(spec); } // request current location list from device to remove dangling locations getSupport().sendCommand("request weather locations", COMMAND_TYPE, CMD_GET_LOCATIONS); } private void sendWeatherSpec(@NonNull final WeatherSpec weatherSpec) { LOG.debug("Send weather for location {}", weatherSpec.location); sendCurrentConditions(weatherSpec); sendDailyForecast(weatherSpec); sendHourlyForecast(weatherSpec); } private void setMeasurementSystem() { final String metricScale = getSupport().getContext().getString(R.string.p_unit_metric); final String imperialScale = getSupport().getContext().getString(R.string.p_unit_imperial); final String measurementSystem = GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, metricScale); LOG.info("Setting measurement system to {}", measurementSystem); int unitValue = TEMPERATURE_SCALE_CELSIUS; if (measurementSystem.equals(imperialScale)) { unitValue = TEMPERATURE_SCALE_FAHRENHEIT; } else if (!measurementSystem.equals(metricScale)) { LOG.warn("Unknown measurement system, defaulting to celsius"); } getSupport().sendCommand( "set temperature scale", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_SET_WEATHER_PREFS) .setWeather(XiaomiProto.Weather.newBuilder().setPrefs( XiaomiProto.WeatherPrefs.newBuilder().setTemperatureScale(unitValue) )) .build() ); } private void onConditionRequestReceived(final XiaomiProto.Command command) { if (command.hasStatus() && command.getStatus() != 0) { LOG.warn("Received request for conditions with unexpected status code {}", command.getStatus()); return; } if (command.hasWeather() && command.getWeather().hasLocation()) { final String locationKey = command.getWeather().getLocation().getCode(); final String locationName = command.getWeather().getLocation().getName(); if (!TextUtils.isEmpty(locationKey) && !TextUtils.isEmpty(locationName)) { LOG.debug("Received request for conditions (location key = {}, name = {})", locationKey, locationName); final List knownWeathers = Weather.getInstance().getWeatherSpecs(); for (WeatherSpec spec : knownWeathers) { if (TextUtils.equals(spec.location, locationName)) { sendWeatherSpec(spec); return; } } } else { LOG.debug("Received request for conditions at current location"); } } final WeatherSpec spec = Weather.getInstance().getWeatherSpec(); if (spec == null) { LOG.warn("Not sending weather conditions: active weather spec is null!"); return; } sendWeatherSpec(Weather.getInstance().getWeatherSpec()); } private static String[] weatherLocationsToStringArray(final Collection locations) { final List result = new ArrayList<>(); for (XiaomiProto.WeatherLocation l : locations) { result.add(String.format("{code=%s, name=%s}", l.getCode(), l.getName())); } return result.toArray(new String[0]); } private static String[] extractWeatherSpecLocations(final Collection specs) { final List result = new ArrayList<>(); for (final WeatherSpec spec : specs) { result.add(StringUtils.ensureNotNull(spec.location)); } return result.toArray(new String[0]); } private void onWeatherLocationsReceived(final XiaomiProto.Command cmd) { // status code 1 = explicitly not supported if (cmd.hasStatus() && cmd.getStatus() == 1) { LOG.warn("Multiple weather locations not supported by this device"); getSupport().setFeatureSupported(XiaomiPreferences.FEAT_MULTIPLE_WEATHER_LOCATIONS, false); // now that the feature flag has been updated, send cached weather onSendWeather(Weather.getInstance().getWeatherSpecs()); return; } if (cmd.hasStatus() && cmd.getStatus() != 0) { LOG.warn("Received unexpected status code for configured weather locations request: {}", cmd.getStatus()); return; } getSupport().setFeatureSupported(XiaomiPreferences.FEAT_MULTIPLE_WEATHER_LOCATIONS, true); if (!cmd.hasWeather() || !cmd.getWeather().hasLocations()) { LOG.warn("Received unexpected payload in response to configured weather locations request"); LOG.debug("Unexpected weather locations command: {}", cmd); } final List retrievedLocations = cmd.getWeather().getLocations().getLocationList(); LOG.debug("Received {} weather locations: {}", retrievedLocations.size(), weatherLocationsToStringArray(retrievedLocations)); // remove any duplicate locations from device before caching locations { final Set duplicateLocations = new HashSet<>(); for (XiaomiProto.WeatherLocation l : retrievedLocations) { if (Collections.frequency(retrievedLocations, l) > 1) { duplicateLocations.add(l); } } if (!duplicateLocations.isEmpty()) { LOG.debug("Removing {} locations which were found as duplicates: {}", duplicateLocations.size(), weatherLocationsToStringArray(duplicateLocations)); getSupport().sendCommand("remove duplicate weather locations", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_REMOVE_LOCATIONS) .setWeather(XiaomiProto.Weather.newBuilder() .setLocations(XiaomiProto.WeatherLocations.newBuilder() .addAllLocation(duplicateLocations))) .build()); } } final Set specLocations = new HashSet<>(); for (final WeatherSpec s : Weather.getInstance().getWeatherSpecs()) { specLocations.add(getWeatherLocationFromSpec(s)); } // remove locations for which a cached weather spec cannot be found { final Set locationsMissingSpec = new HashSet<>(); for (XiaomiProto.WeatherLocation l : retrievedLocations) { if (!specLocations.contains(l)) { locationsMissingSpec.add(l); } } if (!locationsMissingSpec.isEmpty()) { LOG.debug("Removing {} weather locations for which a weather spec is not found cached: {}", locationsMissingSpec.size(), weatherLocationsToStringArray(locationsMissingSpec)); getSupport().sendCommand("remove non-cached weather locations", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_REMOVE_LOCATIONS) .setWeather(XiaomiProto.Weather.newBuilder() .setLocations(XiaomiProto.WeatherLocations.newBuilder() .addAllLocation(locationsMissingSpec))).build()); } } // cache location that were unique final Set presentLocations = new HashSet<>(); for (XiaomiProto.WeatherLocation l : retrievedLocations) { if (specLocations.contains(l) && Collections.frequency(retrievedLocations, l) == 1) { presentLocations.add(l); } } LOG.debug("Caching {} weather locations: {}", presentLocations.size(), weatherLocationsToStringArray(presentLocations)); cachedWeatherLocations.clear(); cachedWeatherLocations.addAll(presentLocations); } public static String unixTimestampToISOWithColons(int timestamp) { return new StringBuilder( new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US) .format(new Date(timestamp * 1000L))) .insert(22, ':') // FIXME: I bet this fails for some, but all this java date craps sucks .toString(); } }