diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java index 2f7757a7..af4d987a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java @@ -132,7 +132,7 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im * @return the characteristic for the given UUID or null * @see #addSupportedService(UUID) */ - protected BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + public BluetoothGattCharacteristic getCharacteristic(UUID uuid) { if (mAvailableCharacteristics == null) { return null; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java index 777b76e2..a7f6d6a3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BtLEQueue.java @@ -61,7 +61,7 @@ public final class BtLEQueue { while (!mDisposed && !mCrashed) { try { Transaction transaction = mTransactions.take(); - internalGattCallback.setTransactionGattCallback(null); + internalGattCallback.reset(); if (!isConnected()) { // TODO: request connection and initialization from the outside and wait until finished @@ -114,7 +114,6 @@ public final class BtLEQueue { } finally { mWaitForActionResultLatch = null; mWaitCharacteristic = null; - internalGattCallback.reset(); } } LOG.info("Queue Dispatch Thread terminated."); @@ -185,6 +184,7 @@ public final class BtLEQueue { } private void handleDisconnected(int status) { + internalGattCallback.reset(); mTransactions.clear(); mAbortTransaction = true; if (mWaitForActionResultLatch != null) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java index 19bfc619..4089d862 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/MiBandSupport.java @@ -3,7 +3,6 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.miband; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.SharedPreferences; -import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.preference.PreferenceManager; import android.widget.Toast; @@ -12,19 +11,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.text.DateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.UUID; -import java.util.concurrent.TimeUnit; -import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; -import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; -import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandFWHelper; @@ -37,11 +31,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSuppo import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.AbortTransactionAction; -import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceBusyAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetProgressAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.FetchActivityOperation; import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; -import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.DEFAULT_VALUE_FLASH_COLOUR; @@ -68,29 +61,10 @@ import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.ge public class MiBandSupport extends AbstractBTLEDeviceSupport { - //temporary buffer, size is a multiple of 60 because we want to store complete minutes (1 minute = 3 bytes) - private static final int activityDataHolderSize = 3 * 60 * 4; // 8h - - private static class ActivityStruct { - public byte[] activityDataHolder = new byte[activityDataHolderSize]; - //index of the buffer above - public int activityDataHolderProgress = 0; - //number of bytes we will get in a single data transfer, used as counter - public int activityDataRemainingBytes = 0; - //same as above, but remains untouched for the ack message - public int activityDataUntilNextHeader = 0; - //timestamp of the single data transfer, incremented to store each minute's data - public GregorianCalendar activityDataTimestampProgress = null; - //same as above, but remains untouched for the ack message - public GregorianCalendar activityDataTimestampToAck = null; - } - private static final Logger LOG = LoggerFactory.getLogger(MiBandSupport.class); private volatile boolean telephoneRinging; private volatile boolean isLocatingDevice; - private ActivityStruct activityStruct; - private DeviceInfo mDeviceInfo; private boolean firmwareInfoSent = false; @@ -234,7 +208,6 @@ public class MiBandSupport extends AbstractBTLEDeviceSupport { private static final byte[] startVibrate = new byte[]{MiBandService.COMMAND_SEND_NOTIFICATION, 1}; private static final byte[] stopVibrate = new byte[]{MiBandService.COMMAND_STOP_MOTOR_VIBRATE}; private static final byte[] reboot = new byte[]{MiBandService.COMMAND_REBOOT}; - private static final byte[] fetch = new byte[]{MiBandService.COMMAND_FETCH_DATA}; private byte[] getNotification(long vibrateDuration, int vibrateTimes, int flashTimes, int flashColour, int originalColour, long flashDuration) { byte[] vibrate = new byte[]{MiBandService.COMMAND_SEND_NOTIFICATION, (byte) 1}; @@ -546,11 +519,7 @@ public class MiBandSupport extends AbstractBTLEDeviceSupport { @Override public void onFetchActivityData() { try { - TransactionBuilder builder = performInitialized("fetch activity data"); -// builder.write(getCharacteristic(MiBandService.UUID_CHARACTERISTIC_LE_PARAMS), getLowLatency()); - builder.add(new SetDeviceBusyAction(getDevice(), getContext().getString(R.string.busy_task_fetch_activity_data), getContext())); - builder.write(getCharacteristic(MiBandService.UUID_CHARACTERISTIC_CONTROL_POINT), fetch); - builder.queue(getQueue()); + new FetchActivityOperation(this).perform(); } catch (IOException ex) { LOG.error("Unable to fetch MI activity data", ex); } @@ -646,9 +615,7 @@ public class MiBandSupport extends AbstractBTLEDeviceSupport { super.onCharacteristicChanged(gatt, characteristic); UUID characteristicUUID = characteristic.getUuid(); - if (MiBandService.UUID_CHARACTERISTIC_ACTIVITY_DATA.equals(characteristicUUID)) { - handleActivityNotif(characteristic.getValue()); - } else if (MiBandService.UUID_CHARACTERISTIC_BATTERY.equals(characteristicUUID)) { + if (MiBandService.UUID_CHARACTERISTIC_BATTERY.equals(characteristicUUID)) { handleBatteryInfo(characteristic.getValue(), BluetoothGatt.GATT_SUCCESS); } else if (MiBandService.UUID_CHARACTERISTIC_NOTIFICATION.equals(characteristicUUID)) { handleNotificationNotif(characteristic.getValue()); @@ -792,151 +759,6 @@ public class MiBandSupport extends AbstractBTLEDeviceSupport { builder.write(characteristic, alarmMessage); } - /** - * Method to handle the incoming activity data. - * There are two kind of messages we currently know: - * - the first one is 11 bytes long and contains metadata (how many bytes to expect, when the data starts, etc.) - * - the second one is 20 bytes long and contains the actual activity data - * - * The first message type is parsed by this method, for every other length of the value param, bufferActivityData is called. - * @see #bufferActivityData(byte[]) - * - * - * @param value - */ - private void handleActivityNotif(byte[] value) { - boolean firstChunk = activityStruct == null; - if (firstChunk) { - activityStruct = new ActivityStruct(); - } - - if (value.length == 11) { - // byte 0 is the data type: 1 means that each minute is represented by a triplet of bytes - int dataType = value[0]; - // byte 1 to 6 represent a timestamp - GregorianCalendar timestamp = parseTimestamp(value, 1); - - // counter of all data held by the band - int totalDataToRead = (value[7] & 0xff) | ((value[8] & 0xff) << 8); - totalDataToRead *= (dataType == 1) ? 3 : 1; - - - // counter of this data block - int dataUntilNextHeader = (value[9] & 0xff) | ((value[10] & 0xff) << 8); - dataUntilNextHeader *= (dataType == 1) ? 3 : 1; - - // there is a total of totalDataToRead that will come in chunks (3 bytes per minute if dataType == 1), - // these chunks are usually 20 bytes long and grouped in blocks - // after dataUntilNextHeader bytes we will get a new packet of 11 bytes that should be parsed - // as we just did - - if (firstChunk && dataUntilNextHeader != 0) { - GB.toast(getContext().getString(R.string.user_feedback_miband_activity_data_transfer, - DateTimeUtils.formatDurationHoursMinutes((totalDataToRead / 3), TimeUnit.MINUTES), - DateFormat.getDateTimeInstance().format(timestamp.getTime())), Toast.LENGTH_LONG, GB.INFO); - } - LOG.info("total data to read: " + totalDataToRead + " len: " + (totalDataToRead / 3) + " minute(s)"); - LOG.info("data to read until next header: " + dataUntilNextHeader + " len: " + (dataUntilNextHeader / 3) + " minute(s)"); - LOG.info("TIMESTAMP: " + DateFormat.getDateTimeInstance().format(timestamp.getTime()).toString() + " magic byte: " + dataUntilNextHeader); - - activityStruct.activityDataRemainingBytes = activityStruct.activityDataUntilNextHeader = dataUntilNextHeader; - activityStruct.activityDataTimestampToAck = (GregorianCalendar) timestamp.clone(); - activityStruct.activityDataTimestampProgress = timestamp; - - } else { - bufferActivityData(value); - } - LOG.debug("activity data: length: " + value.length + ", remaining bytes: " + activityStruct.activityDataRemainingBytes); - - if (activityStruct.activityDataRemainingBytes == 0) { - sendAckDataTransfer(activityStruct.activityDataTimestampToAck, activityStruct.activityDataUntilNextHeader); - } - } - - private GregorianCalendar parseTimestamp(byte[] value, int offset) { - GregorianCalendar timestamp = new GregorianCalendar( - value[offset] + 2000, - value[offset + 1], - value[offset + 2], - value[offset + 3], - value[offset + 4], - value[offset + 5]); - return timestamp; - } - - /** - * Method to store temporarily the activity data values got from the Mi Band. - * - * Since we expect chunks of 20 bytes each, we do not store the received bytes it the length is different. - * - * @param value - */ - private void bufferActivityData(byte[] value) { - - if (activityStruct.activityDataRemainingBytes >= value.length) { - //I don't like this clause, but until we figure out why we get different data sometimes this should work - if (value.length == 20 || value.length == activityStruct.activityDataRemainingBytes) { - System.arraycopy(value, 0, activityStruct.activityDataHolder, activityStruct.activityDataHolderProgress, value.length); - activityStruct.activityDataHolderProgress += value.length; - activityStruct.activityDataRemainingBytes -= value.length; - - if (this.activityDataHolderSize == activityStruct.activityDataHolderProgress) { - flushActivityDataHolder(); - } - } else { - // the length of the chunk is not what we expect. We need to make sense of this data - LOG.warn("GOT UNEXPECTED ACTIVITY DATA WITH LENGTH: " + value.length + ", EXPECTED LENGTH: " + activityStruct.activityDataRemainingBytes); - for (byte b : value) { - LOG.warn("DATA: " + String.format("0x%8x", b)); - } - } - } else { - LOG.error("error buffering activity data: remaining bytes: " + activityStruct.activityDataRemainingBytes + ", received: " + value.length); - } - } - - /** - * empty the local buffer for activity data, arrange the values received in groups of three and - * store them in the DB - */ - private void flushActivityDataHolder() { - if (activityStruct == null) { - LOG.debug("nothing to flush, struct is already null"); - return; - } - LOG.debug("flushing activity data holder"); - byte category, intensity, steps; - - DBHandler dbHandler = null; - try { - dbHandler = GBApplication.acquireDB(); - try (SQLiteDatabase db = dbHandler.getWritableDatabase()) { // explicitly keep the db open while looping over the samples - for (int i = 0; i < activityStruct.activityDataHolderProgress; i += 3) { //TODO: check if multiple of 3, if not something is wrong - category = activityStruct.activityDataHolder[i]; - intensity = activityStruct.activityDataHolder[i + 1]; - steps = activityStruct.activityDataHolder[i + 2]; - - dbHandler.addGBActivitySample( - (int) (activityStruct.activityDataTimestampProgress.getTimeInMillis() / 1000), - SampleProvider.PROVIDER_MIBAND, - (short) (intensity & 0xff), - (short) (steps & 0xff), - category); - activityStruct.activityDataTimestampProgress.add(Calendar.MINUTE, 1); - } - } finally { - activityStruct.activityDataHolderProgress = 0; - } - } catch (Exception ex) { - GB.toast(getContext(), ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR); - } finally { - if (dbHandler != null) { - dbHandler.release(); - } - } - } - - private void handleControlPointResult(byte[] value, int status) { if (status != BluetoothGatt.GATT_SUCCESS) { LOG.warn("Could not write to the control point."); @@ -952,56 +774,6 @@ public class MiBandSupport extends AbstractBTLEDeviceSupport { } } - private void unsetBusy() { - getDevice().unsetBusyTask(); - getDevice().sendDeviceUpdateIntent(getContext()); - } - - /** - * Acknowledge the transfer of activity data to the Mi Band. - * - * After receiving data from the band, it has to be acknowledged. This way the Mi Band will delete - * the data it has on record. - * - * @param time - * @param bytesTransferred - */ - private void sendAckDataTransfer(Calendar time, int bytesTransferred) { - byte[] ack = new byte[]{ - MiBandService.COMMAND_CONFIRM_ACTIVITY_DATA_TRANSFER_COMPLETE, - (byte) (time.get(Calendar.YEAR) - 2000), - (byte) time.get(Calendar.MONTH), - (byte) time.get(Calendar.DATE), - (byte) time.get(Calendar.HOUR_OF_DAY), - (byte) time.get(Calendar.MINUTE), - (byte) time.get(Calendar.SECOND), - (byte) (bytesTransferred & 0xff), - (byte) (0xff & (bytesTransferred >> 8)) - }; - try { - TransactionBuilder builder = performInitialized("send acknowledge"); - builder.write(getCharacteristic(MiBandService.UUID_CHARACTERISTIC_CONTROL_POINT), ack); - builder.queue(getQueue()); - - // flush to the DB after sending the ACK - flushActivityDataHolder(); - - //The last data chunk sent by the miband has always length 0. - //When we ack this chunk, the transfer is done. - if (getDevice().isBusy() && bytesTransferred == 0) { - handleActivityFetchFinish(); - } - } catch (IOException ex) { - LOG.error("Unable to send ack to MI", ex); - } - } - - private void handleActivityFetchFinish() { - LOG.info("Fetching activity data has finished."); - activityStruct = null; - unsetBusy(); - } - private void handleBatteryInfo(byte[] value, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { BatteryInfo info = new BatteryInfo(value); @@ -1145,4 +917,10 @@ public class MiBandSupport extends AbstractBTLEDeviceSupport { } return true; } + + // overridden to make visible to operations + @Override + public TransactionBuilder performInitialized(String taskName) throws IOException { + return super.performInitialized(taskName); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/AbstractMiBandOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/AbstractMiBandOperation.java new file mode 100644 index 00000000..ccca9d0c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/AbstractMiBandOperation.java @@ -0,0 +1,115 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.content.Context; + +import java.io.IOException; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BtLEQueue; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCallback; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.MiBandSupport; + +/** + * Abstract base class for a MiBandOperation, i.e. an operation that does more than + * just sending a few bytes to the band. It typically involves exchanging many messages + * between the mobile and the band. + * + * One operation may execute multiple @{link Transaction transactions} with each + * multiple @{link BTLEAction actions}. + * + * This class implements GattCallback so that subclasses may override those methods + * to handle those events. + * Note: by default all Gatt events are forwarded to MiBandSupport, subclasses may override + * this behavior. + */ +public abstract class AbstractMiBandOperation implements GattCallback, MiBandOperation { + private final MiBandSupport mSupport; + + protected AbstractMiBandOperation(MiBandSupport support) { + mSupport = support; + } + + /** + * Delegates to MiBandSupport and additionally sets this instance as the Gatt + * callback for the transaction. + * @param taskName + * @return + * @throws IOException + */ + public TransactionBuilder performInitialized(String taskName) throws IOException { + TransactionBuilder builder = mSupport.performInitialized(taskName); + builder.setGattCallback(this); + return builder; + } + + protected Context getContext() { + return mSupport.getContext(); + } + + protected GBDevice getDevice() { + return mSupport.getDevice(); + } + + protected BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + return mSupport.getCharacteristic(uuid); + } + + protected BtLEQueue getQueue() { + return mSupport.getQueue(); + } + + protected void unsetBusy() { + getDevice().unsetBusyTask(); + getDevice().sendDeviceUpdateIntent(getContext()); + } + + public MiBandSupport getSupport() { + return mSupport; + } + + // All Gatt callbacks delegated to MiBandSupport + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + mSupport.onConnectionStateChange(gatt, status, newState); + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt) { + mSupport.onServicesDiscovered(gatt); + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + mSupport.onCharacteristicRead(gatt, characteristic, status); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + mSupport.onCharacteristicWrite(gatt, characteristic, status); + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + mSupport.onCharacteristicChanged(gatt, characteristic); + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + mSupport.onDescriptorRead(gatt, descriptor, status); + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + mSupport.onDescriptorWrite(gatt, descriptor, status); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + mSupport.onReadRemoteRssi(gatt, rssi, status); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/FetchActivityOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/FetchActivityOperation.java new file mode 100644 index 00000000..e81dab0b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/FetchActivityOperation.java @@ -0,0 +1,265 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.database.sqlite.SQLiteDatabase; +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.text.DateFormat; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceBusyAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.MiBandSupport; +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class FetchActivityOperation extends AbstractMiBandOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchActivityOperation.class); + private static final byte[] fetch = new byte[]{MiBandService.COMMAND_FETCH_DATA}; + + //temporary buffer, size is a multiple of 60 because we want to store complete minutes (1 minute = 3 bytes) + private static final int activityDataHolderSize = 3 * 60 * 4; // 8h + + private static class ActivityStruct { + public byte[] activityDataHolder = new byte[activityDataHolderSize]; + //index of the buffer above + public int activityDataHolderProgress = 0; + //number of bytes we will get in a single data transfer, used as counter + public int activityDataRemainingBytes = 0; + //same as above, but remains untouched for the ack message + public int activityDataUntilNextHeader = 0; + //timestamp of the single data transfer, incremented to store each minute's data + public GregorianCalendar activityDataTimestampProgress = null; + //same as above, but remains untouched for the ack message + public GregorianCalendar activityDataTimestampToAck = null; + } + + private ActivityStruct activityStruct; + + public FetchActivityOperation(MiBandSupport support) { + super(support); + } + + @Override + public void perform() throws IOException { + TransactionBuilder builder = performInitialized("fetch activity data"); +// builder.write(getCharacteristic(MiBandService.UUID_CHARACTERISTIC_LE_PARAMS), getLowLatency()); + builder.add(new SetDeviceBusyAction(getDevice(), getContext().getString(R.string.busy_task_fetch_activity_data), getContext())); + builder.write(getCharacteristic(MiBandService.UUID_CHARACTERISTIC_CONTROL_POINT), fetch); + builder.queue(getQueue()); + } + + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic) { + UUID characteristicUUID = characteristic.getUuid(); + if (MiBandService.UUID_CHARACTERISTIC_ACTIVITY_DATA.equals(characteristicUUID)) { + handleActivityNotif(characteristic.getValue()); + } else { + super.onCharacteristicChanged(gatt, characteristic); + } + } + + private void handleActivityFetchFinish() { + LOG.info("Fetching activity data has finished."); + activityStruct = null; + unsetBusy(); + } + + /** + * Method to handle the incoming activity data. + * There are two kind of messages we currently know: + * - the first one is 11 bytes long and contains metadata (how many bytes to expect, when the data starts, etc.) + * - the second one is 20 bytes long and contains the actual activity data + * + * The first message type is parsed by this method, for every other length of the value param, bufferActivityData is called. + * @see #bufferActivityData(byte[]) + * + * + * @param value + */ + private void handleActivityNotif(byte[] value) { + boolean firstChunk = activityStruct == null; + if (firstChunk) { + activityStruct = new ActivityStruct(); + } + + if (value.length == 11) { + // byte 0 is the data type: 1 means that each minute is represented by a triplet of bytes + int dataType = value[0]; + // byte 1 to 6 represent a timestamp + GregorianCalendar timestamp = parseTimestamp(value, 1); + + // counter of all data held by the band + int totalDataToRead = (value[7] & 0xff) | ((value[8] & 0xff) << 8); + totalDataToRead *= (dataType == 1) ? 3 : 1; + + + // counter of this data block + int dataUntilNextHeader = (value[9] & 0xff) | ((value[10] & 0xff) << 8); + dataUntilNextHeader *= (dataType == 1) ? 3 : 1; + + // there is a total of totalDataToRead that will come in chunks (3 bytes per minute if dataType == 1), + // these chunks are usually 20 bytes long and grouped in blocks + // after dataUntilNextHeader bytes we will get a new packet of 11 bytes that should be parsed + // as we just did + + if (firstChunk && dataUntilNextHeader != 0) { + GB.toast(getContext().getString(R.string.user_feedback_miband_activity_data_transfer, + DateTimeUtils.formatDurationHoursMinutes((totalDataToRead / 3), TimeUnit.MINUTES), + DateFormat.getDateTimeInstance().format(timestamp.getTime())), Toast.LENGTH_LONG, GB.INFO); + } + LOG.info("total data to read: " + totalDataToRead + " len: " + (totalDataToRead / 3) + " minute(s)"); + LOG.info("data to read until next header: " + dataUntilNextHeader + " len: " + (dataUntilNextHeader / 3) + " minute(s)"); + LOG.info("TIMESTAMP: " + DateFormat.getDateTimeInstance().format(timestamp.getTime()).toString() + " magic byte: " + dataUntilNextHeader); + + activityStruct.activityDataRemainingBytes = activityStruct.activityDataUntilNextHeader = dataUntilNextHeader; + activityStruct.activityDataTimestampToAck = (GregorianCalendar) timestamp.clone(); + activityStruct.activityDataTimestampProgress = timestamp; + + } else { + bufferActivityData(value); + } + LOG.debug("activity data: length: " + value.length + ", remaining bytes: " + activityStruct.activityDataRemainingBytes); + + if (activityStruct.activityDataRemainingBytes == 0) { + sendAckDataTransfer(activityStruct.activityDataTimestampToAck, activityStruct.activityDataUntilNextHeader); + } + } + + /** + * Method to store temporarily the activity data values got from the Mi Band. + * + * Since we expect chunks of 20 bytes each, we do not store the received bytes it the length is different. + * + * @param value + */ + private void bufferActivityData(byte[] value) { + + if (activityStruct.activityDataRemainingBytes >= value.length) { + //I don't like this clause, but until we figure out why we get different data sometimes this should work + if (value.length == 20 || value.length == activityStruct.activityDataRemainingBytes) { + System.arraycopy(value, 0, activityStruct.activityDataHolder, activityStruct.activityDataHolderProgress, value.length); + activityStruct.activityDataHolderProgress += value.length; + activityStruct.activityDataRemainingBytes -= value.length; + + if (this.activityDataHolderSize == activityStruct.activityDataHolderProgress) { + flushActivityDataHolder(); + } + } else { + // the length of the chunk is not what we expect. We need to make sense of this data + LOG.warn("GOT UNEXPECTED ACTIVITY DATA WITH LENGTH: " + value.length + ", EXPECTED LENGTH: " + activityStruct.activityDataRemainingBytes); + for (byte b : value) { + LOG.warn("DATA: " + String.format("0x%8x", b)); + } + } + } else { + LOG.error("error buffering activity data: remaining bytes: " + activityStruct.activityDataRemainingBytes + ", received: " + value.length); + } + } + + /** + * empty the local buffer for activity data, arrange the values received in groups of three and + * store them in the DB + */ + private void flushActivityDataHolder() { + if (activityStruct == null) { + LOG.debug("nothing to flush, struct is already null"); + return; + } + LOG.debug("flushing activity data holder"); + byte category, intensity, steps; + + DBHandler dbHandler = null; + try { + dbHandler = GBApplication.acquireDB(); + try (SQLiteDatabase db = dbHandler.getWritableDatabase()) { // explicitly keep the db open while looping over the samples + for (int i = 0; i < activityStruct.activityDataHolderProgress; i += 3) { //TODO: check if multiple of 3, if not something is wrong + category = activityStruct.activityDataHolder[i]; + intensity = activityStruct.activityDataHolder[i + 1]; + steps = activityStruct.activityDataHolder[i + 2]; + + dbHandler.addGBActivitySample( + (int) (activityStruct.activityDataTimestampProgress.getTimeInMillis() / 1000), + SampleProvider.PROVIDER_MIBAND, + (short) (intensity & 0xff), + (short) (steps & 0xff), + category); + activityStruct.activityDataTimestampProgress.add(Calendar.MINUTE, 1); + } + } finally { + activityStruct.activityDataHolderProgress = 0; + } + } catch (Exception ex) { + GB.toast(getContext(), ex.getMessage(), Toast.LENGTH_LONG, GB.ERROR); + } finally { + if (dbHandler != null) { + dbHandler.release(); + } + } + } + + /** + * Acknowledge the transfer of activity data to the Mi Band. + * + * After receiving data from the band, it has to be acknowledged. This way the Mi Band will delete + * the data it has on record. + * + * @param time + * @param bytesTransferred + */ + private void sendAckDataTransfer(Calendar time, int bytesTransferred) { + byte[] ack = new byte[]{ + MiBandService.COMMAND_CONFIRM_ACTIVITY_DATA_TRANSFER_COMPLETE, + (byte) (time.get(Calendar.YEAR) - 2000), + (byte) time.get(Calendar.MONTH), + (byte) time.get(Calendar.DATE), + (byte) time.get(Calendar.HOUR_OF_DAY), + (byte) time.get(Calendar.MINUTE), + (byte) time.get(Calendar.SECOND), + (byte) (bytesTransferred & 0xff), + (byte) (0xff & (bytesTransferred >> 8)) + }; + try { + TransactionBuilder builder = performInitialized("send acknowledge"); + builder.write(getCharacteristic(MiBandService.UUID_CHARACTERISTIC_CONTROL_POINT), ack); + builder.queue(getQueue()); + + // flush to the DB after sending the ACK + flushActivityDataHolder(); + + //The last data chunk sent by the miband has always length 0. + //When we ack this chunk, the transfer is done. + if (getDevice().isBusy() && bytesTransferred == 0) { + handleActivityFetchFinish(); + } + } catch (IOException ex) { + LOG.error("Unable to send ack to MI", ex); + } + } + + private GregorianCalendar parseTimestamp(byte[] value, int offset) { + GregorianCalendar timestamp = new GregorianCalendar( + value[offset] + 2000, + value[offset + 1], + value[offset + 2], + value[offset + 3], + value[offset + 4], + value[offset + 5]); + return timestamp; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/MiBandOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/MiBandOperation.java new file mode 100644 index 00000000..2cd8f1c4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/miband/operations/MiBandOperation.java @@ -0,0 +1,9 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations; + +import java.io.IOException; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.MiBandSupport; + +public interface MiBandOperation { + public void perform() throws IOException; +}