From 6cc6d2c8ca123217e47dd11d1bd2afca3f0bf126 Mon Sep 17 00:00:00 2001 From: krzys-h Date: Sun, 29 Dec 2019 11:29:40 +0100 Subject: [PATCH] Da Fit: Training data transfer --- .../devices/dafit/DaFitConstants.java | 32 ++- .../devices/dafit/DaFitDeviceCoordinator.java | 2 +- .../devices/dafit/DaFitSampleProvider.java | 13 + .../devices/dafit/DaFitDeviceSupport.java | 116 ++++++++- .../devices/dafit/FetchDataOperation.java | 17 +- .../dafit/TrainingFinishedDataOperation.java | 235 ++++++++++++++++++ .../gadgetbridge/util/ArrayUtils.java | 25 ++ app/src/main/res/values/strings.xml | 2 + 8 files changed, 432 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/dafit/TrainingFinishedDataOperation.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java index 9115247d3..cdcf80b52 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitConstants.java @@ -114,11 +114,31 @@ public class DaFitConstants { public static final byte CMD_HS_DFU = 99; // (?) {1} - enableHsDfu(), {0} - queryHsDfuAddress() - // Activity tracking (?) - public static final byte CMD_QUERY_LAST_DYNAMIC_RATE = 52; // (?) {} -> ??? - public static final byte CMD_QUERY_PAST_HEART_RATE_1 = 53; // (?) {4} - pastHeartRate(), {0} - todayHeartRate(1) -> ??? - public static final byte CMD_QUERY_PAST_HEART_RATE_2 = 54; // (?) {0} - todayHeartRate(2) -> ??? - public static final byte CMD_QUERY_MOVEMENT_HEART_RATE = 55; // (?) {} -> ??? + // Activity/training tracking + + // CMD_QUERY_LAST_DYNAMIC_RATE is triggered immediately after a training recording is finished on the watch. + // The watch sends CMD_QUERY_LAST_DYNAMIC_RATE command to the phone with the first part of the data, and then + // the phone is supposed to respond with empty CMD_QUERY_LAST_DYNAMIC_RATE to retrieve the next part. + // There seems to be no way to query this data later, or to start communication from phone side. + // The data format is uint32 date_recorded, uint8 heart_rate[] (where 0 is invalid measurement and + // data is recorded every 1 minute) + + // CMD_QUERY_MOVEMENT_HEART_RATE returns the summary of last 3 trainings recorded on the watch. + // This is a cyclic buffer, so the watch will first overwrite entry number 0, then 1, then 2, then 0 again + + // CMD_QUERY_PAST_HEART_RATE_1 and CMD_QUERY_PAST_HEART_RATE_2 don't seem to work at all on my watch. + + // All "date recorded" values are in the hardcoded GMT+8 watch timezone + + public static final byte CMD_QUERY_LAST_DYNAMIC_RATE = 52; // TRANSMISSION TRIGGERED FROM WATCH SIDE AFTER FINISHED TRAINING. Does custom packet splitting. The packet takes no data as input. Send the query repeatedly until you get all the data. THE FIRST PACKET IS SENT BY THE WATCH - THE PHONE QUERIES THIS COMMAND TO GET THE NEXT PART. The response starts with one byte: 0 for first packet, 1 for continuation packet, 2 for end of data. 0,time:uint32,measurement:uint8[] 1,measurement:uint8[] 1,measurement:uint8[] 2 + public static final byte CMD_QUERY_PAST_HEART_RATE_1 = 53; // (*) Two arrays built of 4 packets each. See below. todayHeartRate(1) starts at 0 and ends at 3, yesterdayHeartRate() starts at 4 and ends at 7. Sampled every 5 minutes. + public static final byte CMD_QUERY_PAST_HEART_RATE_2 = 54; // (*) An array built of 20 packets. The packet takes the index as input. i.e. {x} -> {data[N*x], data[N*x+1], ..., data[N*x+N-1]} for x in 0-19 -- todayHeartRate(2). Sampled every 1 minute. + public static final byte CMD_QUERY_MOVEMENT_HEART_RATE = 55; // {} -> One packet with 3 entries of 24 bytes each {startTime:uint32, endTime:uint32, validTime:uint16, entry_number:uint8, type:uint8, steps:uint32, distance:uint32, calories:uint16}, everything little endian + + // first byte for CMD_QUERY_LAST_DYNAMIC_RATE packets + public static final byte ARG_TRANSMISSION_FIRST = 0; + public static final byte ARG_TRANSMISSION_NEXT = 1; + public static final byte ARG_TRANSMISSION_LAST = 2; // note: last packet always empty // Health measurements public static final byte CMD_QUERY_TIMING_MEASURE_HEART_RATE = 47; // (*) {} -> ??? @@ -165,7 +185,7 @@ public class DaFitConstants { public static final byte CMD_SWITCH_CAMERA_VIEW = 102; // {} -> {}, outgoing open screen, incoming take photo public static final byte CMD_NOTIFY_PHONE_OPERATION = 103; // ONLY INCOMING! -> {x}, x -> 0 = play/pause, 1 = prev, 2 = next, 3 = reject incoming call) - public static final byte CMD_NOTIFY_WEATHER_CHANGE = 100; // (?) ONLY INCOMING! -> {} + public static final byte CMD_NOTIFY_WEATHER_CHANGE = 100; // ONLY INCOMING! -> {} - when the watch really wants us to retransmit the weather again (it seems to often happen after stopping training - running the training blocks access to main menu so I guess it restarts afterwards or something). Will repeat whenever navigating the menu where the weather should be, and weather won't be visible on watch screen until that happens. public static final byte ARG_OPERATION_PLAY_PAUSE = 0; public static final byte ARG_OPERATION_PREV_SONG = 1; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java index a4bd39b7a..9aca13545 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitDeviceCoordinator.java @@ -173,7 +173,7 @@ public class DaFitDeviceCoordinator extends AbstractDeviceCoordinator { @Override public boolean supportsActivityTracks() { - return false; + return true; } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitSampleProvider.java index c9f282abe..58ff5bb6f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitSampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/dafit/DaFitSampleProvider.java @@ -107,6 +107,19 @@ public class DaFitSampleProvider extends AbstractSampleProvider. */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.dafit; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.util.Log; +import android.util.Pair; +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Calendar; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.Logging; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.dafit.DaFitConstants; +import nodomain.freeyourgadget.gadgetbridge.devices.dafit.DaFitSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.DaFitActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEOperation; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.OperationStatus; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class TrainingFinishedDataOperation extends AbstractBTLEOperation { + + private static final Logger LOG = LoggerFactory.getLogger(FetchDataOperation.class); + + private final byte[] firstPacketData; + private final long firstPacketTimeInMillis; + ByteArrayOutputStream data = new ByteArrayOutputStream(); + + private DaFitPacketIn packetIn = new DaFitPacketIn(); + + public TrainingFinishedDataOperation(DaFitDeviceSupport support, byte[] firstPacketData) { + super(support); + this.firstPacketData = firstPacketData; + this.firstPacketTimeInMillis = Calendar.getInstance().getTimeInMillis(); + } + + @Override + protected void prePerform() { + getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_training_data)); + getDevice().sendDeviceUpdateIntent(getContext()); + } + + @Override + protected void doPerform() { + GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_training_data), true, 0, getContext()); + handleTrainingHealthRatePacket(firstPacketData, true); + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + if (!isOperationRunning()) + { + LOG.error("onCharacteristicChanged but operation is not running!"); + } + else + { + UUID charUuid = characteristic.getUuid(); + if (charUuid.equals(DaFitConstants.UUID_CHARACTERISTIC_DATA_IN)) + { + if (packetIn.putFragment(characteristic.getValue())) { + Pair packet = DaFitPacketIn.parsePacket(packetIn.getPacket()); + packetIn = new DaFitPacketIn(); + if (packet != null) { + byte packetType = packet.first; + byte[] payload = packet.second; + + if (handlePacket(packetType, payload)) + return true; + } + } + } + + } + + return super.onCharacteristicChanged(gatt, characteristic); + } + + private boolean handlePacket(byte packetType, byte[] payload) { + if (packetType == DaFitConstants.CMD_QUERY_LAST_DYNAMIC_RATE) { + handleTrainingHealthRatePacket(payload, false); + return true; + } + if (packetType == DaFitConstants.CMD_QUERY_MOVEMENT_HEART_RATE) { + handleTrainingSummaryDataPacket(payload); + return true; + } + return false; + } + + + private void handleTrainingHealthRatePacket(byte[] payload, boolean isFirst) { + Log.i("TRAINING DATA", "data: " + Logging.formatBytes(payload)); + byte sequenceType = payload[0]; + if (isFirst != (sequenceType == DaFitConstants.ARG_TRANSMISSION_FIRST)) + throw new IllegalArgumentException("Expected packet to be " + (isFirst ? "first" : "continued") + " but got packet of type " + sequenceType); + if (sequenceType == DaFitConstants.ARG_TRANSMISSION_LAST && payload.length > 1) + throw new IllegalArgumentException("Last packet shouldn't have any data"); + + data.write(payload, 1, payload.length - 1); + + if (sequenceType != DaFitConstants.ARG_TRANSMISSION_LAST) + queryMoreData(); + else + processAllData(); + } + + private void queryMoreData() { + try { + TransactionBuilder builder = performInitialized("TrainingFinishedDataOperation"); + getSupport().sendPacket(builder, DaFitPacketOut.buildPacket(DaFitConstants.CMD_QUERY_LAST_DYNAMIC_RATE, new byte[0])); + builder.queue(getQueue()); + } catch (IOException e) { + e.printStackTrace(); + GB.toast(getContext(), "Error fetching training data: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); + GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); + operationFinished(); + } + } + + private void processAllData() { + byte[] completeData = data.toByteArray(); + Log.i("HAVE COMPLETE DATA", Logging.formatBytes(completeData)); + ByteBuffer dataBuffer = ByteBuffer.wrap(completeData); + dataBuffer.order(ByteOrder.LITTLE_ENDIAN); + + Calendar dateRecorded = Calendar.getInstance(); + dateRecorded.setTime(DaFitConstants.WatchTimeToLocalTime(dataBuffer.getInt())); + + // NOTE: The first sample always matches dateRecorded (which is aligned to the minute) + // The last sample is saved at the moment the recording is stopped (and this code starts executing) + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + User user = DBHelper.getUser(dbHandler.getDaoSession()); + Device device = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()); + + DaFitSampleProvider provider = new DaFitSampleProvider(getDevice(), dbHandler.getDaoSession()); + + Log.i("START DATE", dateRecorded.getTime().toString()); + while (dataBuffer.hasRemaining()) + { + int measurement = dataBuffer.get() & 0xFF; + if (!dataBuffer.hasRemaining()) + dateRecorded.setTimeInMillis(firstPacketTimeInMillis); // the last sample is captured exactly at the end of measurement + + Log.i("MEASUREMENT", "at " + dateRecorded.getTime().toString() + " was " + measurement); + + DaFitActivitySample sample = new DaFitActivitySample(); + sample.setDevice(device); + sample.setUser(user); + sample.setProvider(provider); + sample.setTimestamp((int)(dateRecorded.getTimeInMillis() / 1000)); + + sample.setRawKind(DaFitSampleProvider.ACTIVITY_NOT_MEASURED); // Training type will be taken later from CMD_QUERY_MOVEMENT_HEART_RATE (it's not present in the main data packet) + sample.setDataSource(DaFitSampleProvider.SOURCE_TRAINING_HEARTRATE); + + sample.setBatteryLevel(ActivitySample.NOT_MEASURED); + sample.setSteps(ActivitySample.NOT_MEASURED); + sample.setDistanceMeters(ActivitySample.NOT_MEASURED); + sample.setCaloriesBurnt(ActivitySample.NOT_MEASURED); + + sample.setHeartRate(measurement != 0 ? measurement : ActivitySample.NOT_MEASURED); + sample.setBloodPressureSystolic(ActivitySample.NOT_MEASURED); + sample.setBloodPressureDiastolic(ActivitySample.NOT_MEASURED); + sample.setBloodOxidation(ActivitySample.NOT_MEASURED); + + provider.addGBActivitySample(sample); + LOG.info("Adding a training sample: " + sample.toString()); + + dateRecorded.add(Calendar.MINUTE, 1); + } + } catch (Exception ex) { + ex.printStackTrace(); + GB.toast(getContext(), "Error saving samples: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); + GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); + } + + try { + TransactionBuilder builder = performInitialized("TrainingFinishedDataOperation fetch training type"); + getSupport().sendPacket(builder, DaFitPacketOut.buildPacket(DaFitConstants.CMD_QUERY_MOVEMENT_HEART_RATE, new byte[] { })); + builder.queue(getQueue()); + } catch (IOException e) { + e.printStackTrace(); + GB.toast(getContext(), "Error fetching training data: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); + GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); + operationFinished(); + } + } + + private void handleTrainingSummaryDataPacket(byte[] payload) + { + getSupport().handleTrainingData(payload); + + GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_training_data_finished), false, 0, getContext()); + operationFinished(); + } + + @Override + protected void operationFinished() { + operationStatus = OperationStatus.FINISHED; + if (getDevice() != null && getDevice().isConnected()) { + unsetBusy(); + GB.signalActivityDataFinish(); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java index cb68d12b9..822a883e9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java @@ -119,5 +119,30 @@ public class ArrayUtils { if (array[i] == value) return i; } return -1; + /** + * Check if a byte array contains all zeros + * @param array The array to check + * @param startIndex The starting position + * @param length Number of elements to check + * @return true if all checked elements were == 0, false otherwise + */ + public static boolean isAllZeros(byte[] array, int startIndex, int length) + { + for(int i = startIndex; i < startIndex + length; i++) + { + if (array[i] != 0) + return false; + } + return true; + } + + /** + * Check if a byte array contains all zeros + * @param array The array to check + * @return true if all checked elements were == 0, false otherwise + */ + public static boolean isAllZeros(byte[] array) + { + return isAllZeros(array, 0, array.length); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a33b4f39..494d79fca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -709,6 +709,8 @@ Fetching sleep respiratory rate data Fetching temperature data Fetching statistics + Fetching last training data + Training data recieved! From %1$s to %2$s Wearing left or right? Wearing direction