1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-25 10:05:49 +01:00

Xiaomi: rework weather service

This commit is contained in:
MrYoranimo 2024-03-13 18:32:07 +01:00
parent 033e977491
commit a5ff360497
12 changed files with 814 additions and 248 deletions

View File

@ -568,10 +568,6 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator {
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
}
public boolean supportsMultipleWeatherLocations() {
return false;
}
public boolean supports(final GBDevice device, final String feature) {
return getPrefs(device).getBoolean(feature, false);
}

View File

@ -0,0 +1,186 @@
/* Copyright (C) 2017-2024 Andreas Shimokawa, Yoran Vulker
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.xiaomi;
public class XiaomiWeatherConditions {
// Most of these conditions are the same on Huami and ZeppOS (see HuamiWeatherConditions), but
// some have been renamed and a few were added. Nonetheless, some more detailed conditions,
// such as drizzle, tornado, and tropical storm, do not have an equivalent
// These conditions were mapped by sending the current condition and iterating over the condition
// code.
// At least the Xiaomi Watch S1 Active is known to crash if the condition cannot be mapped
// to an icon while shown (above 33), so take that into account when adding new conditions below.
public static final byte CLEAR_SKY = 0;
public static final byte CLOUDY = 1; // may appear with a moon on some models
public static final byte OVERCAST = 2;
public static final byte SHOWER = 3; // 'light rain' (two drops) on MB8P
public static final byte THUNDERSTORM = 4;
public static final byte HAIL = 5;
public static final byte SLEET = 6;
public static final byte LIGHT_RAIN = 7; // 'light rain' (two drops) on MB8P
public static final byte MODERATE_RAIN = 8; // 'moderate rain' (three drops) on MB8P
public static final byte HEAVY_RAINFALL = 9; // 'heavy rain' (four drops) on MB8P
public static final byte RAINSTORM = 10; // 'heavy rain' (four drops) on MB8P
public static final byte DOWNPOUR = 11; // 'heavy rain' (four drops) on MB8P
public static final byte HEAVY_RAINSTORM = 12; // 'heavy rain' (four drops) on MB8P
public static final byte SNOW_SHOWERS = 13; // 'light snow' (1 flake) on MB8P
public static final byte LIGHT_SNOW = 14; // 'light snow' (1 flake) on MB8P
public static final byte MODERATE_SNOW = 15; // 'moderate snow' (1 large and 1 small flake) on MB8P
public static final byte HEAVY_SNOW = 16; // 'heavy snow' (multiple flakes}) on MB8P
public static final byte BLIZZARD = 17; // 'heavy snow' (multiple flakes}) on MB8P
public static final byte MIST = 18;
public static final byte FREEZING_RAIN = 19; // 'sleet' on MB8P
public static final byte SANDSTORM = 20; // sandstorm
public static final byte LIGHT_TO_MODERATE_RAIN = 21; // 'moderate rain' on MB8P
public static final byte MODERATE_TO_HEAVY_RAIN = 22; // 'heavy rain' on MB8P
public static final byte HEAVY_RAIN_TO_RAINSTORM = 23; // 'heavy rain' on MB8P
public static final byte RAINSTORM_TO_DOWNPOUR = 24; // 'heavy rain' on MB8P
public static final byte DOWNPOUR_TO_HEAVY_RAINSTORM = 25; // 'heavy rain' on MB8P
public static final byte LIGHT_TO_MODERATE_SNOW = 26; // 'moderate snow' on MB8P
public static final byte MODERATE_TO_HEAVY_SNOW = 27; // 'heavy snow' on MB8P
public static final byte HEAVY_SNOW_TO_BLIZZARD = 28; // 'heavy snow' on MB8P
public static final byte DUST = 29; // 'sandstorm' on MB8P
public static final byte WINDY = 30; // 'sandstorm' on MB8P
public static final byte STRONG_SANDSTORM = 31; // 'sandstorm' on MB8P
public static final byte MODERATE_FOG = 32; // 'mist' on MB8P
public static final byte SNOW = 33; // 'light snow' on MB8P
// Please read the comment above before adding new condition codes here
public static byte convertOwmConditionToXiaomi(int openWeatherMapCondition) {
// openweathermap.org conditions:
// http://openweathermap.org/weather-conditions
switch (openWeatherMapCondition) {
//Group 2xx: Thunderstorm
case 200: // thunderstorm with light rain
case 201: // thunderstorm with rain
case 202: // thunderstorm with heavy rain
case 210: // light thunderstorm
case 211: // thunderstorm
case 212: // heavy thunderstorm
case 221: // ragged thunderstorm
case 230: // thunderstorm with light drizzle
case 231: // thunderstorm with drizzle
case 232: // thunderstorm with heavy drizzle
return THUNDERSTORM;
//Group 3xx: Drizzle
case 300: // light intensity drizzle
case 301: // drizzle
case 302: // heavy intensity drizzle
case 310: // light intensity drizzle rain
case 311: // drizzle rain
case 312: // heavy intensity drizzle rain
case 313: // shower rain and drizzle
case 314: // heavy shower rain and drizzle
case 321: // shower drizzle
// for lack of a better transcription, drizzle -> light rain
//Group 5xx: Rain
case 500: // light rain
return LIGHT_RAIN;
case 501: // moderate rain
return MODERATE_RAIN;
case 502: // heavy intensity rain
return RAINSTORM;
case 503: // very heavy rain
return DOWNPOUR;
case 504: // extreme rain
return HEAVY_RAINSTORM;
case 511: // freezing rain
return FREEZING_RAIN;
case 520: // light intensity shower rain
case 521: // shower rain
return SHOWER;
case 522: // heavy intensity shower rain
case 531: // ragged shower rain
return HEAVY_RAINFALL;
//Group 6xx: Snow
case 600: // light snow
return LIGHT_SNOW;
case 601: // snow
return MODERATE_SNOW;
case 602: // heavy snow
return HEAVY_SNOW;
case 611: // sleet
case 612: // shower sleet
return SLEET;
case 615: // light rain and snow
case 616: // rain and snow
return SNOW_SHOWERS;
case 620: // light shower snow
case 621: // shower snow
return HEAVY_SNOW;
case 622: // heavy shower snow
return BLIZZARD;
//Group 7xx: Atmosphere
case 701: // mist
case 711: // smoke
case 721: // haze
case 731: // sand/dust whirls
case 741: // fog
return MIST;
case 751: // sand
return SANDSTORM;
case 761: // dust
case 762: // volcanic ash
case 771: // squalls
return DUST;
case 781: // tornado
case 900: // tornado
return WINDY;
//Group 800: Clear
case 800: // clear sky
return CLEAR_SKY;
//Group 80x: Clouds
case 801: // few clouds
case 802: // scattered clouds
case 803: // broken clouds
case 804: // overcast clouds
return OVERCAST;
//Group 90x: Extreme
case 901: // tropical storm
return WINDY;
case 903: // cold
case 904: // hot
return CLEAR_SKY;
case 905: // windy
return WINDY;
case 906: // hail
return HAIL;
//Group 9xx: Additional
case 951: // calm
case 952: // light breeze
case 953: // gentle breeze
case 954: // moderate breeze
case 955: // fresh breeze
return CLEAR_SKY;
case 956: // strong breeze
case 957: // high wind/near gale
case 958: // gale
case 959: // severe gale
case 960: // storm
case 961: // violent storm
case 902: // hurricane
case 962: // hurricane
return WINDY;
default:
return CLEAR_SKY;
}
}
}

View File

@ -67,9 +67,4 @@ public class MiBand7ProCoordinator extends XiaomiCoordinator {
// no PAI nor vitality score
return false;
}
@Override
public boolean supportsMultipleWeatherLocations() {
return true;
}
}

View File

@ -78,9 +78,4 @@ public class MiBand8Coordinator extends XiaomiCoordinator {
// FIXME still has some issues
return true;
}
@Override
public boolean supportsMultipleWeatherLocations() {
return true;
}
}

View File

@ -78,9 +78,4 @@ public class MiBand8ActiveCoordinator extends XiaomiCoordinator {
// FIXME still has some issues
return true;
}
@Override
public boolean supportsMultipleWeatherLocations() {
return true;
}
}

View File

@ -67,11 +67,6 @@ public class RedmiWatch3Coordinator extends XiaomiCoordinator {
return R.drawable.ic_device_amazfit_bip_disabled;
}
@Override
public boolean supportsMultipleWeatherLocations() {
return true;
}
@Override
public int getContactsSlotCount(final GBDevice device) {
return 10; // TODO:verify

View File

@ -67,11 +67,6 @@ public class RedmiWatch3ActiveCoordinator extends XiaomiCoordinator {
return R.drawable.ic_device_amazfit_bip_disabled;
}
@Override
public boolean supportsMultipleWeatherLocations() {
return true;
}
@Override
public int getContactsSlotCount(final GBDevice device) {
return 10; // TODO:verify

View File

@ -72,9 +72,4 @@ public class XiaomiWatchS1ActiveCoordinator extends XiaomiCoordinator {
public int getDisabledIconResource() {
return R.drawable.ic_device_miwatch_disabled;
}
@Override
public boolean supportsMultipleWeatherLocations() {
return true;
}
}

View File

@ -55,6 +55,7 @@ public final class XiaomiPreferences {
public static final String FEAT_SCREEN_ON_ON_NOTIFICATIONS = "feat_screen_on_on_notifications";
public static final String FEAT_CAMERA_REMOTE = "feat_camera_remote";
public static final String FEAT_WIDGETS = "feat_widgets";
public static final String FEAT_MULTIPLE_WEATHER_LOCATIONS = "feat_multiple_weather_locations";
private XiaomiPreferences() {
// util class

View File

@ -42,6 +42,7 @@ import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiFWHelper;
@ -399,7 +400,7 @@ public class XiaomiSupport extends AbstractDeviceSupport {
@Override
public void onSendWeather(final ArrayList<WeatherSpec> weatherSpecs) {
weatherService.onSendWeather(weatherSpecs.get(0));
weatherService.onSendWeather(weatherSpecs);
}
@Override
@ -519,6 +520,11 @@ public class XiaomiSupport extends AbstractDeviceSupport {
}
}
public void setFeatureSupported(final String featureKey, final boolean supported) {
LOG.debug("Setting feature {} -> {}", featureKey, supported ? "supported" : "not supported");
evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(featureKey, supported));
}
private static final String[] EMOJI_SOURCE = new String[]{
"\uD83D\uDE0D", // 😍
"\uD83D\uDE18", // 😘

View File

@ -16,46 +16,220 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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.huami.HuamiWeatherConditions;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiCoordinator;
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 {
public static final int COMMAND_TYPE = 10;
private static final Logger LOG = LoggerFactory.getLogger(XiaomiWeatherService.class);
private static final int CMD_TEMPERATURE_UNIT_GET = 9;
private static final int CMD_TEMPERATURE_UNIT_SET = 10;
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_SET_DAILY_WEATHER = 1;
private static final int CMD_SET_CURRENT_LOCATION = 6;
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<XiaomiProto.WeatherLocation> cachedWeatherLocations = new HashSet<>();
public XiaomiWeatherService(final XiaomiSupport support) {
super(support);
}
@Override
public void handleCommand(final XiaomiProto.Command cmd) {
// TODO
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 initialize() {
// TODO setMeasurementSystem();, or request
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();
@ -65,144 +239,409 @@ public class XiaomiWeatherService extends AbstractXiaomiService {
return false;
}
public void onSendWeather(final WeatherSpec weatherSpec) {
String timestamp = unixTimetstampToISOWithColons(weatherSpec.timestamp);
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();
}
final XiaomiCoordinator coordinator = getSupport().getCoordinator();
private static XiaomiProto.WeatherLocation getWeatherLocationFromSpec(final WeatherSpec weatherSpec) {
return XiaomiProto.WeatherLocation.newBuilder()
.setCode(getLocationKey(weatherSpec.location))
.setName(StringUtils.ensureNotNull(weatherSpec.location))
.build();
}
if (coordinator.supportsMultipleWeatherLocations()) {
// TODO actually support multiple locations (primary + 4 secondary)
getSupport().sendCommand(
"set current location",
XiaomiProto.Command.newBuilder()
.setType(COMMAND_TYPE)
.setSubtype(CMD_SET_CURRENT_LOCATION)
.setWeather(XiaomiProto.Weather.newBuilder().setCurrentLocation(
XiaomiProto.WeatherCurrentLocation.newBuilder()
.setLocation(XiaomiProto.WeatherLocation.newBuilder()
.setCode("accu:123456") // FIXME:AccuWeather code (we do not have it here)
.setName(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)
))
.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) : "")));
}
getSupport().sendCommand(
"set current weather",
XiaomiProto.Command.newBuilder()
.setType(COMMAND_TYPE)
.setSubtype(CMD_SET_CURRENT_WEATHER)
.setWeather(XiaomiProto.Weather.newBuilder().setCurrent(
XiaomiProto.WeatherCurrent.newBuilder()
.setTimeLocation(XiaomiProto.WeatherCurrentTimeLocation.newBuilder()
.setTimestamp(timestamp)
.setUnk2("")
.setCurrentLocationString(weatherSpec.location)
.setCurrentLocationCode("accu:123456") // FIXME:AccuWeather code (we do not have it here)
.setUnk5(true)
)
.setWeatherCondition(HuamiWeatherConditions.mapToAmazfitBipWeatherCode(weatherSpec.currentConditionCode)) // *SEEMS* to work
.setTemperature(XiaomiProto.WeatherCurrentTemperature.newBuilder()
.setDegrees(weatherSpec.currentTemp - 273) // TODO: support inches for weather
.setSymbol("")
)
.setHumidity(XiaomiProto.WeatherCurrentHumidity.newBuilder()
.setHumidity(weatherSpec.currentHumidity)
.setSymbol("%")
)
.setWind(XiaomiProto.WeatherCurrentWind.newBuilder()
.setWind(weatherSpec.windSpeedAsBeaufort())
.setSymbol("")
)
.setUv(XiaomiProto.WeatherCurrentUVIndex.newBuilder()
.setUnk1("")
.setIndex(Math.round(weatherSpec.uvIndex)) // This is sent as an sint but seems to be displayed with a decimal point
)
.setAQI(XiaomiProto.WeatherCurrentAQI.newBuilder()
.setAQIText("Unknown") // some string like "Moderate"
.setAQI(weatherSpec.airQuality != null && weatherSpec.airQuality.aqi >= 0 ? weatherSpec.airQuality.aqi : 0)
)
.setWarning(XiaomiProto.WeatherCurrentWarning.newBuilder()
.addCurrentWarning1(XiaomiProto.WeatherCurrentWarning1.newBuilder()
.setCurrentWarningText("")
.setCurrentWarningSeverityText("")
)
)
.setPressure(weatherSpec.pressure)
))
.build()
);
// 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());
if (weatherSpec.forecasts != null) {
XiaomiProto.WeatherDailyList.Builder dailyListBuilder = XiaomiProto.WeatherDailyList.newBuilder();
int daysToSend = Math.min(7, weatherSpec.forecasts.size());
for (int i = 0; i < daysToSend; i++) {
WeatherSpec.AirQuality airQuality = weatherSpec.forecasts.get(i).airQuality;
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();
dailyListBuilder.addForecastDay(XiaomiProto.WeatherDailyForecastDay.newBuilder()
.setAQI(XiaomiProto.DailyAQI.newBuilder()
.setAQIText("")
.setAQI(airQuality != null && airQuality.aqi >= 0 ? airQuality.aqi : 0)
)
.setUnk2(XiaomiProto.DailyUnk2.newBuilder()
.setUnk1(HuamiWeatherConditions.mapToAmazfitBipWeatherCode(weatherSpec.forecasts.get(i).conditionCode)) // TODO: verify
.setUnk2(0)
)
.setHighLowTemp(XiaomiProto.DailyHighLowTemp.newBuilder()
.setHigh(weatherSpec.forecasts.get(i).maxTemp - 273)
.setLow(weatherSpec.forecasts.get(i).minTemp - 273)
)
.setTemperatureSymbol("")
.setSunriseSunset(XiaomiProto.DailySunriseSunset.newBuilder()
.setSunrise(weatherSpec.forecasts.get(i).sunRise != 0 ? unixTimetstampToISOWithColons(weatherSpec.forecasts.get(i).sunRise) : "")
.setSunset(weatherSpec.forecasts.get(i).sunSet != 0 ? unixTimetstampToISOWithColons(weatherSpec.forecasts.get(i).sunSet) : "")
)
);
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<WeatherSpec> weatherSpecList) {
if (supportsMultipleWeatherLocations()) {
sendWeatherSpecList(weatherSpecList);
} else {
if (!weatherSpecList.isEmpty() && weatherSpecList.get(0) != null) {
final WeatherSpec specToSend = weatherSpecList.get(0);
addWeatherLocationFromSpec(specToSend);
sendWeatherSpec(specToSend);
}
getSupport().sendCommand(
"set daily forecast",
XiaomiProto.Command.newBuilder()
.setType(COMMAND_TYPE)
.setSubtype(CMD_SET_DAILY_WEATHER)
.setWeather(XiaomiProto.Weather.newBuilder().setDaily(
XiaomiProto.WeatherDaily.newBuilder()
.setTimeLocation(XiaomiProto.WeatherCurrentTimeLocation.newBuilder()
.setTimestamp(timestamp)
.setUnk2("")
.setCurrentLocationString(weatherSpec.location)
.setCurrentLocationCode("accu:123456") // FIXME:AccuWeather code (we do not have it here)
.setUnk5(true)
)
.setDailyList(dailyListBuilder)
))
.build()
);
}
}
private void sendWeatherSpecList(@NonNull final List<WeatherSpec> weatherSpecs) {
// FIXME: the MB8P seems to support more locations than the original app allows, which is
// undoubtedly also applicable to other devices
List<WeatherSpec> specsToSend = weatherSpecs.subList(0, Math.min(5, weatherSpecs.size()));
List<XiaomiProto.WeatherLocation> 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 Prefs prefs = getDevicePrefs();
final String measurementSystem = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric");
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);
final int unitValue = "metric".equals(measurementSystem) ? 1 : 2;
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 unit",
"set temperature scale",
XiaomiProto.Command.newBuilder()
.setType(COMMAND_TYPE)
.setSubtype(CMD_TEMPERATURE_UNIT_SET)
.setWeather(XiaomiProto.Weather.newBuilder().setTemperatureUnit(
XiaomiProto.WeatherTemperatureUnit.newBuilder().setUnit(unitValue)
.setSubtype(CMD_SET_WEATHER_PREFS)
.setWeather(XiaomiProto.Weather.newBuilder().setPrefs(
XiaomiProto.WeatherPrefs.newBuilder().setTemperatureScale(unitValue)
))
.build()
);
}
private String unixTimetstampToISOWithColons(int timestamp) {
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<WeatherSpec> 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<XiaomiProto.WeatherLocation> locations) {
final List<String> 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<WeatherSpec> specs) {
final List<String> 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<XiaomiProto.WeatherLocation> 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<XiaomiProto.WeatherLocation> 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<XiaomiProto.WeatherLocation> 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<XiaomiProto.WeatherLocation> 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<XiaomiProto.WeatherLocation> 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)))

View File

@ -734,127 +734,95 @@ message NotificationIconPackage {
message Weather {
optional WeatherCurrent current = 1;
optional WeatherDaily daily = 2;
optional WeatherForecast forecast = 2;
// 10, 6 request without payload?
// response to 10, 5 (get location list) | payload of 10, 6 (set, update location list) | payload of 10, 8 (remove)
optional WeatherLocations locations = 4;
// 10, 5 set current | 10, 7 create | 10, 8 delete
optional WeatherCurrentLocation currentLocation = 4;
// 10, 7 create
optional WeatherLocation create = 5;
// indication payload of 10, 3 (requested update) | payload of 10, 7 (set current, add to list)
optional WeatherLocation location = 5;
// 10, 10
optional WeatherTemperatureUnit temperatureUnit = 6;
optional WeatherPrefs prefs = 6;
}
message WeatherCurrent {
optional WeatherCurrentTimeLocation timeLocation = 1;
optional uint32 weatherCondition = 2;
optional WeatherCurrentTemperature temperature = 3;
optional WeatherCurrentHumidity humidity= 4;
optional WeatherCurrentWind wind = 5;
optional WeatherCurrentUVIndex uv = 6;
optional WeatherCurrentAQI AQI = 7;
optional WeatherCurrentWarning warning = 8; // Seems to be an array?
optional WeatherMetadata metadata = 1;
required uint32 weatherCondition = 2;
optional WeatherUnitValue temperature = 3;
optional WeatherUnitValue humidity = 4;
optional WeatherUnitValue wind = 5;
optional WeatherUnitValue uv = 6;
optional WeatherUnitValue aqi = 7;
optional WeatherWarnings warning = 8; // Seems to be an array?
optional float pressure = 9;
}
message WeatherCurrentTimeLocation {
optional string timestamp = 1;
optional string unk2 = 2;
optional string currentLocationString = 3;
optional string currentLocationCode = 4;
optional bool unk5 = 5; // default location?
message WeatherMetadata {
required string publicationTimestamp = 1;
required string cityName = 2;
required string locationName = 3;
optional string locationKey = 4;
optional bool isCurrentLocation = 5; // default location?
}
message WeatherCurrentTemperature {
optional string symbol = 1;
optional sint32 degrees = 2;
message WeatherUnitValue {
required string unit = 1;
required sint32 value = 2;
}
message WeatherCurrentHumidity {
optional string symbol = 1;
optional sint32 humidity = 2;
message WeatherWarnings {
repeated WeatherWarning warning = 1;
}
message WeatherCurrentWind {
optional string symbol = 1;
optional sint32 wind = 2;
message WeatherWarning {
required string type = 1;
required string level = 2;
optional string title = 3;
optional string description = 4;
optional string id = 5;
}
message WeatherCurrentUVIndex {
optional string unk1 = 1;
optional sint32 index = 2;
message WeatherLocations {
repeated WeatherLocation location = 1;
}
message WeatherCurrentAQI {
optional string AQIText = 1;
optional sint32 AQI = 2;
message WeatherForecast {
required WeatherMetadata metadata = 1;
required ForecastEntries entries = 2;
}
message WeatherCurrentWarning {
repeated WeatherCurrentWarning1 currentWarning1 = 1;
message ForecastEntries {
repeated ForecastEntry entry = 1;
}
message WeatherCurrentWarning1 {
optional string currentWarningText = 1;
optional string currentWarningSeverityText = 2;
optional string currentWarningTitle = 3;
optional string currentWarningDescription = 4;
optional string unk5 = 5;
}
message WeatherCurrentLocation {
optional WeatherLocation location = 1;
}
message WeatherDaily {
required WeatherCurrentTimeLocation timeLocation = 1;
required WeatherDailyList dailyList = 2;
}
message WeatherDailyList {
repeated WeatherDailyForecastDay forecastDay = 1;
}
message WeatherDailyForecastDay {
optional DailyAQI AQI = 1;
optional DailyUnk2 unk2 = 2;
optional DailyHighLowTemp highLowTemp = 3;
message ForecastEntry {
optional WeatherUnitValue aqi = 1;
optional WeatherRange conditionRange = 2;
optional WeatherRange temperatureRange = 3;
optional string temperatureSymbol = 4;
optional DailySunriseSunset sunriseSunset = 5;
optional WeatherSunriseSunset sunriseSunset = 5;
optional WeatherUnitValue wind = 6;
}
message DailyAQI {
optional string AQIText = 1;
optional sint32 AQI = 2;
message WeatherRange {
required sint32 from = 1;
required sint32 to = 2;
}
message DailyUnk2 {
optional sint32 unk1 = 1;
optional sint32 unk2 = 2;
}
message DailyHighLowTemp {
optional sint32 low = 1;
optional sint32 high = 2;
}
message DailySunriseSunset {
optional string sunrise = 1;
optional string sunset = 2;
message WeatherSunriseSunset {
required string sunrise = 1;
required string sunset = 2;
}
message WeatherLocation {
optional string code = 1;
required string code = 1;
optional string name = 2;
}
message WeatherUnknown1 {
optional float unknown12 = 12;
}
message WeatherTemperatureUnit {
optional uint32 unit = 1; // 1 celsius 2 fahrenheit
message WeatherPrefs {
optional uint32 temperatureScale = 1; // 1 celsius 2 fahrenheit
optional uint32 weatherWarningsEnabled = 2; // 0 = unsupported, 1 = enabled, 2 = disabled
}
//