652 lines
29 KiB
Java
652 lines
29 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;
|
|
|
|
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
|
|
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() {
|
|
|
|
}
|
|
}
|