Moyoung: Basic support for V2 workouts sync

This commit is contained in:
Arjan Schrijver
2025-06-11 20:58:00 +02:00
parent bf72bfd8e3
commit 785b3466c0
3 changed files with 211 additions and 9 deletions
@@ -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;
@@ -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());
}
}
}
@@ -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