mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Moyoung: Basic support for V2 workouts sync
This commit is contained in:
+6
@@ -138,6 +138,12 @@ public class MoyoungConstants {
|
||||
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
|
||||
|
||||
public static final byte CMD_QUERY_V2_WORKOUT = (byte) 0xb2;
|
||||
public static final byte CMD_QUERY_V2_WORKOUT_LIST_REQUEST = 0x00;
|
||||
public static final byte CMD_QUERY_V2_WORKOUT_LIST_RESPONSE = 0x01;
|
||||
public static final byte CMD_QUERY_V2_WORKOUT_DETAIL_REQUEST = 0x02;
|
||||
public static final byte CMD_QUERY_V2_WORKOUT_DETAIL_RESPONSE = 0x03;
|
||||
|
||||
// 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;
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/* Copyright (C) 2025 Arjan Schrijver
|
||||
|
||||
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 <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.moyoung;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.util.Pair;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.Logging;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.MoyoungConstants;
|
||||
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 FetchWorkoutsV2Operation extends AbstractBTLEOperation<MoyoungDeviceSupport> {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(FetchWorkoutsV2Operation.class);
|
||||
|
||||
private int totalWorkouts = 0;
|
||||
private int receivedWorkouts = 0;
|
||||
|
||||
private MoyoungPacketIn packetIn = new MoyoungPacketIn();
|
||||
|
||||
public FetchWorkoutsV2Operation(MoyoungDeviceSupport support) {
|
||||
super(support);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prePerform() {
|
||||
getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data));
|
||||
getDevice().sendDeviceUpdateIntent(getContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPerform() throws IOException {
|
||||
TransactionBuilder builder = performInitialized("FetchWorkoutsV2Operation");
|
||||
getSupport().sendPacket(builder, MoyoungPacketOut.buildPacket(getSupport().getMtu(), MoyoungConstants.CMD_QUERY_V2_WORKOUT, new byte[] { MoyoungConstants.CMD_QUERY_V2_WORKOUT_LIST_REQUEST }));
|
||||
builder.queue(getQueue());
|
||||
|
||||
updateProgressAndCheckFinish();
|
||||
}
|
||||
|
||||
@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(MoyoungConstants.UUID_CHARACTERISTIC_DATA_IN)) {
|
||||
if (packetIn.putFragment(characteristic.getValue())) {
|
||||
Pair<Byte, byte[]> packet = MoyoungPacketIn.parsePacket(packetIn.getPacket());
|
||||
packetIn = new MoyoungPacketIn();
|
||||
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 == MoyoungConstants.CMD_QUERY_V2_WORKOUT) {
|
||||
byte subtype = payload[0];
|
||||
switch (subtype) {
|
||||
case MoyoungConstants.CMD_QUERY_V2_WORKOUT_LIST_RESPONSE:
|
||||
decodeWorkoutsList(payload);
|
||||
break;
|
||||
case MoyoungConstants.CMD_QUERY_V2_WORKOUT_DETAIL_RESPONSE:
|
||||
decodeWorkoutDetails(payload);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void decodeWorkoutsList(byte[] data) {
|
||||
LOG.info("Decoding workouts list packet");
|
||||
totalWorkouts = data.length / 5;
|
||||
requestWorkoutDetails(receivedWorkouts);
|
||||
}
|
||||
|
||||
private void decodeWorkoutDetails(byte[] data) {
|
||||
LOG.info("Decoding workout details packet");
|
||||
getSupport().handleTrainingData(data);
|
||||
receivedWorkouts++;
|
||||
updateProgressAndCheckFinish();
|
||||
if (receivedWorkouts < totalWorkouts) {
|
||||
requestWorkoutDetails(receivedWorkouts);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestWorkoutDetails(int workoutId) {
|
||||
try {
|
||||
TransactionBuilder builder = performInitialized("FetchWorkoutsV2Operation");
|
||||
byte[] payload = new byte[]{
|
||||
MoyoungConstants.CMD_QUERY_V2_WORKOUT_DETAIL_REQUEST,
|
||||
(byte) workoutId
|
||||
};
|
||||
getSupport().sendPacket(builder, MoyoungPacketOut.buildPacket(getSupport().getMtu(), MoyoungConstants.CMD_QUERY_V2_WORKOUT, payload));
|
||||
builder.queue(getQueue());
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error while sending workout details request: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProgressAndCheckFinish() {
|
||||
int percentage = 0;
|
||||
if (totalWorkouts > 0) {
|
||||
percentage = 100 * receivedWorkouts / totalWorkouts;
|
||||
}
|
||||
GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_activity_data), true, percentage, getContext());
|
||||
LOG.debug("Fetching activity data status: {} out of {}", receivedWorkouts, totalWorkouts);
|
||||
if (percentage == 100) {
|
||||
operationFinished();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void operationFinished() {
|
||||
operationStatus = OperationStatus.FINISHED;
|
||||
if (getDevice() != null && getDevice().isConnected()) {
|
||||
unsetBusy();
|
||||
GB.signalActivityDataFinish(getDevice());
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-9
@@ -1024,6 +1024,15 @@ public class MoyoungDeviceSupport extends AbstractBTLEDeviceSupport {
|
||||
LOG.error("Error fetching data: ", e);
|
||||
}
|
||||
}
|
||||
if ((dataTypes & RecordedDataTypes.TYPE_GPS_TRACKS) != 0)
|
||||
{
|
||||
LOG.info("Fetching workouts from watch");
|
||||
try {
|
||||
new FetchWorkoutsV2Operation(this).perform();
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error fetching workouts: ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Runnable updateIdleStepsRunnable = new Runnable() {
|
||||
@@ -1281,25 +1290,55 @@ public class MoyoungDeviceSupport extends AbstractBTLEDeviceSupport {
|
||||
|
||||
public void handleTrainingData(byte[] data)
|
||||
{
|
||||
if (data.length % 24 != 0)
|
||||
throw new IllegalArgumentException();
|
||||
int protocolVersion = 0;
|
||||
int trainingBytesLength = 24;
|
||||
if (data.length % 24 == 0) {
|
||||
protocolVersion = 1;
|
||||
} else if (data.length % 26 == 0) {
|
||||
protocolVersion = 2;
|
||||
trainingBytesLength = 26;
|
||||
}
|
||||
if (protocolVersion == 0) {
|
||||
LOG.error("Invalid training data received");
|
||||
return;
|
||||
}
|
||||
|
||||
for(int i = 0; i < data.length / 24; i++)
|
||||
{
|
||||
if (ArrayUtils.isAllZeros(data, 24*i, 24)) // no data recorded in this slot
|
||||
for(int i = 0; i < data.length / trainingBytesLength; i++) {
|
||||
if (protocolVersion == 1 && ArrayUtils.isAllZeros(data, trainingBytesLength * i, trainingBytesLength)) {
|
||||
LOG.info("Skipping empty workout details packet");
|
||||
continue;
|
||||
}
|
||||
if (protocolVersion == 2 && ArrayUtils.isAllZeros(data, 2, trainingBytesLength - 2)) {
|
||||
LOG.info("Skipping empty workout details packet");
|
||||
continue;
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(data, 24 * i, 24);
|
||||
ByteBuffer buffer = ByteBuffer.wrap(data, trainingBytesLength * i, trainingBytesLength);
|
||||
buffer.order(ByteOrder.LITTLE_ENDIAN);
|
||||
byte num = 0;
|
||||
byte avgHR = 0;
|
||||
if (protocolVersion == 2) {
|
||||
buffer.get(); // skip packet subtype
|
||||
num = buffer.get();
|
||||
}
|
||||
Date startTime = MoyoungConstants.WatchTimeToLocalTime(buffer.getInt());
|
||||
Date endTime = MoyoungConstants.WatchTimeToLocalTime(buffer.getInt());
|
||||
int validTime = buffer.getShort();
|
||||
byte num = buffer.get(); // == i
|
||||
if (protocolVersion == 1) {
|
||||
num = buffer.get(); // == i
|
||||
} else {
|
||||
avgHR = buffer.get();
|
||||
}
|
||||
byte type = buffer.get();
|
||||
int steps = buffer.getInt();
|
||||
int distance = buffer.getInt();
|
||||
int calories = buffer.getShort();
|
||||
LOG.info("Training data: start=" + startTime + " end=" + endTime + " totalTimeWithoutPause=" + validTime + " num=" + num + " type=" + type + " steps=" + steps + " distance=" + distance + " calories=" + calories);
|
||||
int calories;
|
||||
if (protocolVersion == 1) {
|
||||
calories = buffer.getShort();
|
||||
} else {
|
||||
calories = buffer.getInt();
|
||||
}
|
||||
LOG.info("Training data: start=" + startTime + " end=" + endTime + " totalTimeWithoutPause=" + validTime + " num=" + num + " type=" + type + " steps=" + steps + " avgHR=" + avgHR + " distance=" + distance + " calories=" + calories);
|
||||
|
||||
// NOTE: We are ignoring the step/distance/calories data here
|
||||
// If we had the phone connected, the realtime data is already stored anyway, and I'm
|
||||
@@ -1337,6 +1376,8 @@ public class MoyoungDeviceSupport extends AbstractBTLEDeviceSupport {
|
||||
summary.setStartTime(startTime);
|
||||
summary.setEndTime(endTime);
|
||||
|
||||
summary.setRawSummaryData(data);
|
||||
|
||||
summaryDao.insert(summary);
|
||||
|
||||
// NOTE: The type format from device maps directly to the database format
|
||||
|
||||
Reference in New Issue
Block a user