1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-02 11:26:09 +02:00
Gadgetbridge/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java
José Rebelo 81aef0bf35 Add support for multiple weather locations
Introduce the concept of primary and secondary weathers:

* Primary weather keeps the same behavior as previously across all weather providers, so it's non-breaking. This location is not necessarily the current location, just the primary weather location set by the user.
* The GenericWeatherReceiver now has a new extra WeatherSecondaryJson, that receives a json list with secondary weather locations.

It's guaranteed that the primary weather always exists, so the list of WeatherSpecs provided to devices is never empty. Update all support classes accordingly.
2024-03-29 21:10:40 +00:00

658 lines
30 KiB
Java

/* Copyright (C) 2024 José Rebelo
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.service.devices.cmfwatchpro;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.Context;
import android.content.SharedPreferences;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.TimeZone;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.model.Contact;
import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements CmfCharacteristic.Handler {
private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class);
public static final UUID UUID_SERVICE_CMF_CMD = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb");
public static final UUID UUID_CHARACTERISTIC_CMF_COMMAND_READ = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb");
public static final UUID UUID_CHARACTERISTIC_CMF_COMMAND_WRITE = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb");
public static final UUID UUID_SERVICE_CMF_DATA = UUID.fromString("02f00000-0000-0000-0000-00000000ffe0");
public static final UUID UUID_CHARACTERISTIC_CMF_DATA_WRITE = UUID.fromString("02f00000-0000-0000-0000-00000000ffe1");
public static final UUID UUID_CHARACTERISTIC_CMF_DATA_READ = UUID.fromString("02f00000-0000-0000-0000-00000000ffe2");
// An a5 byte is used a lot in single payloads, probably as a "proof of encryption"?
public static final byte A5 = (byte) 0xa5;
private CmfCharacteristic characteristicCommandRead;
private CmfCharacteristic characteristicCommandWrite;
private CmfCharacteristic characteristicDataRead;
private CmfCharacteristic characteristicDataWrite;
private final CmfActivitySync activitySync = new CmfActivitySync(this);
private final CmfPreferences preferences = new CmfPreferences(this);
private CmfDataUploader dataUploader;
protected MediaManager mediaManager = null;
public CmfWatchProSupport() {
super(LOG);
addSupportedService(UUID_SERVICE_CMF_CMD);
addSupportedService(UUID_SERVICE_CMF_DATA);
}
@Override
public boolean useAutoConnect() {
return true;
}
@Override
public boolean getImplicitCallbackModify() {
return false;
}
@Override
public boolean getSendWriteRequestResponse() {
return true;
}
@Override
protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
final BluetoothGattCharacteristic btCharacteristicCommandRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_COMMAND_READ);
if (btCharacteristicCommandRead == null) {
LOG.warn("Characteristic command read is null, will attempt to reconnect");
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
return builder;
}
final BluetoothGattCharacteristic btCharacteristicCommandWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_COMMAND_WRITE);
if (btCharacteristicCommandWrite == null) {
LOG.warn("Characteristic command write is null, will attempt to reconnect");
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
return builder;
}
final BluetoothGattCharacteristic btCharacteristicDataWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_DATA_WRITE);
if (btCharacteristicDataWrite == null) {
LOG.warn("Characteristic data write is null, will attempt to reconnect");
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
return builder;
}
final BluetoothGattCharacteristic btCharacteristicDataRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_DATA_READ);
if (btCharacteristicDataRead == null) {
LOG.warn("Characteristic data read is null, will attempt to reconnect");
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
return builder;
}
dataUploader = new CmfDataUploader(this);
characteristicCommandRead = new CmfCharacteristic(btCharacteristicCommandRead, this);
characteristicCommandWrite = new CmfCharacteristic(btCharacteristicCommandWrite, null);
characteristicDataRead = new CmfCharacteristic(btCharacteristicDataRead, dataUploader);
characteristicDataWrite = new CmfCharacteristic(btCharacteristicDataWrite, null);
final byte[] secretKey = getSecretKey(getDevice());
characteristicCommandRead.setSessionKey(secretKey);
characteristicCommandWrite.setSessionKey(secretKey);
characteristicDataRead.setSessionKey(secretKey);
characteristicDataWrite.setSessionKey(secretKey);
builder.notify(btCharacteristicCommandWrite, true);
builder.notify(btCharacteristicCommandRead, true);
builder.notify(btCharacteristicDataWrite, true);
builder.notify(btCharacteristicDataRead, true);
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.AUTHENTICATING, getContext()));
sendCommand(builder, CmfCommand.AUTH_PHONE_NAME, ArrayUtils.addAll(new byte[]{A5}, Build.MODEL.getBytes(StandardCharsets.UTF_8)));
return builder;
}
@Override
public void setContext(final GBDevice device, final BluetoothAdapter adapter, final Context context) {
super.setContext(device, adapter, context);
mediaManager = new MediaManager(context);
}
@Override
protected Prefs getDevicePrefs() {
return super.getDevicePrefs();
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt,
final BluetoothGattCharacteristic characteristic) {
if (super.onCharacteristicChanged(gatt, characteristic)) {
return true;
}
final UUID characteristicUUID = characteristic.getUuid();
final byte[] value = characteristic.getValue();
if (characteristicUUID.equals(characteristicCommandRead.getCharacteristicUUID())) {
characteristicCommandRead.onCharacteristicChanged(value);
return true;
} else if (characteristicUUID.equals(characteristicDataRead.getCharacteristicUUID())) {
characteristicDataRead.onCharacteristicChanged(value);
return true;
}
LOG.warn("Unhandled characteristic changed: {} {}", characteristicUUID, GB.hexdump(value));
return false;
}
@Override
public void onMtuChanged(final BluetoothGatt gatt, final int mtu, final int status) {
super.onMtuChanged(gatt, mtu, status);
characteristicCommandRead.setMtu(mtu);
characteristicCommandWrite.setMtu(mtu);
characteristicDataRead.setMtu(mtu);
characteristicDataWrite.setMtu(mtu);
}
@Override
public void onCommand(final CmfCommand cmd, final byte[] payload) {
if (activitySync.onCommand(cmd, payload)) {
return;
}
if (preferences.onCommand(cmd, payload)) {
return;
}
switch (cmd) {
case AUTH_WATCH_MAC:
LOG.debug("Got auth watch mac, requesting nonce");
sendCommand("auth request nonce", CmfCommand.AUTH_NONCE_REQUEST, A5);
return;
case AUTH_NONCE_REPLY:
LOG.debug("Got auth nonce");
try {
final MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
sha256.update(payload);
sha256.update(getSecretKey(getDevice()));
final byte[] digest = sha256.digest();
final byte[] sessionKey = ArrayUtils.subarray(digest, 0, 16);
LOG.debug("New session key: {}", GB.hexdump(sessionKey));
characteristicCommandRead.setSessionKey(sessionKey);
characteristicCommandWrite.setSessionKey(sessionKey);
characteristicDataRead.setSessionKey(sessionKey);
characteristicDataWrite.setSessionKey(sessionKey);
} catch (final GeneralSecurityException e) {
LOG.error("Failed to compute session key from auth nonce", e);
return;
}
sendCommand("auth confirm", CmfCommand.AUTHENTICATED_CONFIRM_REQUEST, A5);
return;
case AUTHENTICATED_CONFIRM_REPLY:
LOG.debug("Authentication confirmed, starting phase 2 initialization");
final TransactionBuilder phase2builder = createTransactionBuilder("phase 2 initialize");
setTime(phase2builder);
sendCommand(phase2builder, CmfCommand.FIRMWARE_VERSION_GET);
sendCommand(phase2builder, CmfCommand.SERIAL_NUMBER_GET);
//sendCommand(phase2builder, CmfCommand.STANDING_REMINDER_GET);
//sendCommand(phase2builder, CmfCommand.WATER_REMINDER_GET);
//sendCommand(phase2builder, CmfCommand.CONTACTS_GET);
//sendCommand(phase2builder, CmfCommand.ALARMS_GET);
// TODO premature to mark as initialized?
phase2builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
phase2builder.queue(getQueue());
return;
case BATTERY:
final int battery = payload[0] & 0xff;
final boolean charging = payload[1] == 0x01;
LOG.debug("Got battery: level={} charging={}", battery, charging);
final GBDeviceEventBatteryInfo eventBatteryInfo = new GBDeviceEventBatteryInfo();
eventBatteryInfo.level = battery;
eventBatteryInfo.state = charging ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL;
evaluateGBDeviceEvent(eventBatteryInfo);
return;
case FIRMWARE_VERSION_RET:
final String[] fwParts = new String[payload.length];
for (int i = 0; i < payload.length; i++) {
fwParts[i] = String.valueOf(payload[i]);
}
final String fw = String.join(".", fwParts);
LOG.debug("Got firmware version: {}", fw);
final GBDeviceEventVersionInfo gbDeviceEventVersionInfo = new GBDeviceEventVersionInfo();
gbDeviceEventVersionInfo.fwVersion = fw;
gbDeviceEventVersionInfo.fwVersion2 = "N/A";
//gbDeviceEventVersionInfo.hwVersion = "?"; // TODO how?
evaluateGBDeviceEvent(gbDeviceEventVersionInfo);
return;
case SERIAL_NUMBER_RET:
if (payload.length != (payload[0] & 0xff) + 1) {
LOG.warn("Unexpected serial number payload length: {}, expected {}", payload.length, (payload[0] & 0xff));
return;
}
final String serialNumber = new String(ArrayUtils.subarray(payload, 1, payload.length - 2));
LOG.debug("Got serial number: {}", serialNumber);
final GBDeviceEventUpdateDeviceInfo gbDeviceEventUpdateDeviceInfo = new GBDeviceEventUpdateDeviceInfo("SERIAL: ", serialNumber);
evaluateGBDeviceEvent(gbDeviceEventUpdateDeviceInfo);
return;
case FIND_PHONE:
final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
if (payload[0] == 1) {
findPhoneEvent.event = GBDeviceEventFindPhone.Event.START;
} else {
findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP;
}
evaluateGBDeviceEvent(findPhoneEvent);
return;
case MUSIC_INFO_ACK:
LOG.debug("Got music info ack");
break;
case MUSIC_BUTTON:
final GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl();
switch (BLETypeConversions.toUint16(payload)) {
case 0x0003:
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN;
break;
case 0x0103:
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEUP;
break;
case 0x0001:
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PAUSE;
break;
case 0x0101:
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PLAY;
break;
case 0x0102:
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.NEXT;
break;
case 0x0002:
deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PREVIOUS;
break;
default:
LOG.warn("Unexpected media button key {}", GB.hexdump(payload));
return;
}
LOG.debug("Got media button {}", deviceEventMusicControl.event);
evaluateGBDeviceEvent(deviceEventMusicControl);
break;
default:
LOG.warn("Unhandled command: {}", cmd);
}
}
public void sendCommand(final String taskName, final CmfCommand cmd, final byte... payload) {
final TransactionBuilder builder = createTransactionBuilder(taskName);
sendCommand(builder, cmd, payload);
builder.queue(getQueue());
}
public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte... payload) {
characteristicCommandWrite.sendCommand(builder, cmd, payload);
}
public void sendData(final String taskName, final CmfCommand cmd, final byte... payload) {
final TransactionBuilder builder = createTransactionBuilder(taskName);
characteristicDataWrite.sendCommand(builder, cmd, payload);
builder.queue(getQueue());
}
private static byte[] getSecretKey(final GBDevice device) {
final byte[] authKeyBytes = new byte[16];
final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
final String authKey = sharedPrefs.getString("authkey", "").trim();
if (StringUtils.isNotBlank(authKey)) {
final byte[] srcBytes;
// Allow both with and without 0x, to avoid user mistakes
if (authKey.length() == 34 && authKey.startsWith("0x")) {
srcBytes = GB.hexStringToByteArray(authKey.trim().substring(2));
} else {
srcBytes = GB.hexStringToByteArray(authKey.trim());
}
System.arraycopy(srcBytes, 0, authKeyBytes, 0, Math.min(srcBytes.length, 16));
}
return authKeyBytes;
}
@Override
public void onNotification(final NotificationSpec notificationSpec) {
if (!getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS, true)) {
LOG.debug("App notifications disabled - ignoring");
return;
}
final String senderOrTitle = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.getFirstOf(
notificationSpec.sender,
notificationSpec.title
);
final String body = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.getFirstOf(notificationSpec.body, "");
final byte[] senderOrTitleBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(senderOrTitle, 20); // TODO confirm max
final byte[] bodyBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(body, 128); // TODO confirm max
final ByteBuffer buf = ByteBuffer.allocate(7 + senderOrTitleBytes.length + bodyBytes.length)
.order(ByteOrder.BIG_ENDIAN);
buf.put(CmfNotificationIcon.forNotification(notificationSpec).getCode());
buf.put((byte) 0x00); // ?
buf.putInt((int) (notificationSpec.when / 1000));
buf.put((byte) senderOrTitleBytes.length);
buf.put(senderOrTitleBytes);
buf.put(bodyBytes);
sendCommand("send notification", CmfCommand.APP_NOTIFICATION, buf.array());
}
@Override
public void onSetContacts(final ArrayList<? extends Contact> contacts) {
final ByteBuffer buf = ByteBuffer.allocate(57 * contacts.size()).order(ByteOrder.BIG_ENDIAN);
for (final Contact contact : contacts) {
final byte[] nameBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(contact.getName(), 32);
buf.put(nameBytes);
buf.put(new byte[32 - nameBytes.length]);
final byte[] numberBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(contact.getNumber(), 25);
buf.put(numberBytes);
buf.put(new byte[25 - numberBytes.length]);
}
sendCommand("set contacts", CmfCommand.CONTACTS_SET, ArrayUtils.subarray(buf.array(), 0, buf.position()));
}
@Override
public void onSetTime() {
final TransactionBuilder builder = createTransactionBuilder("set time");
setTime(builder);
builder.queue(getQueue());
}
private void setTime(final TransactionBuilder builder) {
final Calendar cal = Calendar.getInstance();
final ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
buf.putInt((int) (cal.getTimeInMillis() / 1000));
buf.putInt(TimeZone.getDefault().getOffset(cal.getTimeInMillis()));
sendCommand(builder, CmfCommand.TIME, buf.array());
}
@Override
public void onSetAlarms(final ArrayList<? extends Alarm> alarms) {
final ByteBuffer buf = ByteBuffer.allocate(40 * alarms.size()).order(ByteOrder.BIG_ENDIAN);
int i = 0;
for (final Alarm alarm : alarms) {
if (alarm.getUnused()) {
continue;
}
buf.putInt(alarm.getHour() * 3600 + alarm.getMinute() * 60);
buf.put((byte) i++);
buf.put((byte) (alarm.getEnabled() ? 0x01 : 0x00));
buf.put((byte) alarm.getRepetition());
buf.put((byte) 0xff); // ?
buf.put(new byte[24]); // ?
// alarm labels do not show up on watch, even in official app
final byte[] labelBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(alarm.getTitle(), 8);
buf.put(new byte[8 - labelBytes.length]);
buf.put(labelBytes);
}
sendCommand("set alarms", CmfCommand.ALARMS_SET, ArrayUtils.subarray(buf.array(), 0, buf.position()));
}
@Override
public void onSetCallState(final CallSpec callSpec) {
super.onSetCallState(callSpec); // TODO onSetCallState
}
@Override
public void onSetCannedMessages(final CannedMessagesSpec cannedMessagesSpec) {
super.onSetCannedMessages(cannedMessagesSpec); // TODO onSetCannedMessages
}
@Override
public void onSetMusicState(final MusicStateSpec stateSpec) {
if (mediaManager.onSetMusicState(stateSpec)) {
sendMusicStateToDevice();
}
}
@Override
public void onSetPhoneVolume(final float ignoredVolume) {
sendMusicStateToDevice();
}
@Override
public void onSetMusicInfo(final MusicSpec musicSpec) {
if (mediaManager.onSetMusicInfo(musicSpec)) {
sendMusicStateToDevice();
}
}
private void sendMusicStateToDevice() {
final MusicSpec musicSpec = mediaManager.getBufferMusicSpec();
final MusicStateSpec musicStateSpec = mediaManager.getBufferMusicStateSpec();
final byte stateByte;
if (musicSpec == null || musicStateSpec == null) {
stateByte = 0x00;
} else if (musicStateSpec.state == MusicStateSpec.STATE_PLAYING) {
stateByte = 0x02;
} else {
stateByte = 0x01;
}
final byte[] track;
final byte[] artist;
if (musicSpec != null) {
track = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(musicSpec.track, 63);
artist = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(musicSpec.artist, 63);
} else {
track = new byte[0];
artist = new byte[0];
}
final AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
final int volumeLevel = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
final int volumeMax = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
final ByteBuffer buf = ByteBuffer.allocate(131);
buf.put(stateByte);
buf.put((byte) volumeLevel);
buf.put((byte) volumeMax);
buf.put(track);
buf.put(new byte[64 - track.length]);
buf.put(artist);
buf.put(new byte[64 - artist.length]);
sendCommand("set music info", CmfCommand.MUSIC_INFO_SET, buf.array());
}
@Override
public void onInstallApp(final Uri uri) {
dataUploader.onInstallApp(uri);
}
@Override
public void onAppInfoReq() {
super.onAppInfoReq(); // TODO onAppInfoReq
}
@Override
public void onAppStart(final UUID uuid, final boolean start) {
super.onAppStart(uuid, start); // TODO onAppStart for watchfaces
}
@Override
public void onFetchRecordedData(final int dataTypes) {
sendCommand("fetch recorded data step 1", CmfCommand.ACTIVITY_FETCH_1, A5);
}
@Override
public void onReset(final int flags) {
if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) != 0) {
sendCommand("factory reset", CmfCommand.FACTORY_RESET, A5);
} else {
LOG.warn("Unknown reset flags: {}", String.format("0x%x", flags));
}
}
@Override
public void onSetHeartRateMeasurementInterval(final int seconds) {
preferences.onSetHeartRateMeasurementInterval(seconds);
}
@Override
public void onSendConfiguration(final String config) {
preferences.onSendConfiguration(config);
}
@Override
public void onFindDevice(final boolean start) {
if (!start) {
return;
}
sendCommand("find device", CmfCommand.FIND_WATCH);
}
@Override
public void onSendWeather(final ArrayList<WeatherSpec> weatherSpecs) {
final WeatherSpec weatherSpec = weatherSpecs.get(0);
// TODO consider adjusting the condition code for clear/sunny so "clear" at night doesn't show a sunny icon (perhaps 23 decimal)?
// Each weather entry takes up 9 bytes
// There are 7 of those weather entries - 7*9 bytes
// Then there are 24-hour entries of temp and weather condition (2 bytes each)
// Then finally the location name as bytes - allow for 30 bytes, watch auto-scrolls
final ByteBuffer buf = ByteBuffer.allocate((7*9) + (24*2) + 30).order(ByteOrder.BIG_ENDIAN);
// start with the current day's weather
buf.put(Weather.mapToCmfCondition(weatherSpec.currentConditionCode));
buf.put((byte) (weatherSpec.currentTemp - 273 + 100)); // convert Kelvin to C, add 100
buf.put((byte) (weatherSpec.todayMaxTemp - 273 + 100)); // convert Kelvin to C, add 100
buf.put((byte) (weatherSpec.todayMinTemp - 273 + 100)); // convert Kelvin to C, add 100
buf.put((byte) weatherSpec.currentHumidity);
buf.putShort((short) weatherSpec.airQuality.aqi);
buf.put((byte) weatherSpec.uvIndex); // UV index isn't shown. uvi decimal/100, so 0x07 = 700 UVI.
buf.put((byte) weatherSpec.windSpeed); // isn't shown by watch, unsure of correct units
// find out how many future days' forecasts are available
int maxForecastsAvailable = weatherSpec.forecasts.size();
// For each day of the forecast
for (int i=0; i < 6; i++) {
if (i < maxForecastsAvailable) {
WeatherSpec.Daily forecastDay = weatherSpec.forecasts.get(i);
buf.put((byte) (Weather.mapToCmfCondition(forecastDay.conditionCode))); // weather condition flag
buf.put((byte) (forecastDay.maxTemp - 273 + 100)); // temp in C (not shown in future days' forecasts)
buf.put((byte) (forecastDay.maxTemp - 273 + 100)); // max temp in C, + 100
buf.put((byte) (forecastDay.minTemp - 273 + 100)); // min temp in C, + 100
buf.put((byte) forecastDay.humidity); // humidity as a %
try { // AQI data might not be available for the full 7 day forecast.
buf.putShort((short) weatherSpec.airQuality.aqi);
} catch (java.lang.NullPointerException ex) {
buf.putShort((short) 0);
}
buf.put((byte) forecastDay.uvIndex); // UV index isn't shown. uvi decimal/100, so 0x07 = 700 UVI.
buf.put((byte) forecastDay.windSpeed); // isn't shown by watch, unsure of correct units
} else {
// we need to provide a dummy forecast as there's no data available
buf.put((byte) 0x00); // NULL weather condition
buf.put((byte) 0x01); // -99 C temp temp
buf.put((byte) 0x01); // -99 C max temp
buf.put((byte) 0x01); // -99 C min temp
buf.put((byte) 0x00); // 0 humidity
buf.putShort((short) 0); // aqi
buf.put((byte) 0x00); // 0 UV index
buf.put((byte) 0x00); // 0 wind speed
}
}
// now add the hourly data for today - just condition and temperature
int maxHourlyForecastsAvailable = weatherSpec.hourly.size();
for (int i=0; i < 24; i++) {
if (i < maxHourlyForecastsAvailable) {
WeatherSpec.Hourly forecastHr = weatherSpec.hourly.get(i);
buf.put((byte) (forecastHr.temp - 273 + 100)); // temperature
buf.put((byte) forecastHr.conditionCode); // condition
} else {
buf.put((byte) (weatherSpec.currentTemp - 273 + 100)); // assume current temp
buf.put((byte) (Weather.mapToCmfCondition(weatherSpec.currentConditionCode))); // current condition
}
}
// place name - watch scrolls after ~10 chars
buf.put(StringUtils.truncate(weatherSpec.location, 30).getBytes(StandardCharsets.UTF_8));
sendCommand("send weather", CmfCommand.WEATHER_SET_1, buf.array());
}
@Override
public void onTestNewFunction() {
}
}