Colmi V76: Fix workout parsing

This commit is contained in:
José Rebelo
2026-05-31 17:35:52 +01:00
parent 92619bb03f
commit 4a03038d3a
5 changed files with 166 additions and 20 deletions
@@ -48,20 +48,21 @@ public class MoyoungWorkoutSummaryParser implements ActivitySummaryParser {
final ActivitySummaryData summaryData = new ActivitySummaryData(); final ActivitySummaryData summaryData = new ActivitySummaryData();
int protocolVersion = 0; final int protocolVersion;
if (rawSummaryData.length % 24 == 0) { if (rawSummaryData.length % 24 == 0) {
protocolVersion = 1; protocolVersion = 1;
} else if (rawSummaryData.length % 26 == 0) { } else if (rawSummaryData.length % 26 == 0) {
protocolVersion = 2; protocolVersion = 2;
} } else if (rawSummaryData.length % 30 == 0) {
if (protocolVersion == 0) { protocolVersion = 3;
LOG.error("Invalid raw data"); } else {
LOG.error("Unknown protocol version");
return summary; return summary;
} }
byte num = 0; byte num = 0;
byte avgHR = 0; byte avgHR = 0;
if (protocolVersion == 2) { if (protocolVersion == 2 || protocolVersion == 3) {
buffer.get(); // skip packet subtype buffer.get(); // skip packet subtype
num = buffer.get(); num = buffer.get();
} }
@@ -70,8 +71,10 @@ public class MoyoungWorkoutSummaryParser implements ActivitySummaryParser {
int validTime = buffer.getShort(); int validTime = buffer.getShort();
if (protocolVersion == 1) { if (protocolVersion == 1) {
num = buffer.get(); // == i num = buffer.get(); // == i
} else { } else if (protocolVersion == 2) {
avgHR = buffer.get(); avgHR = buffer.get();
} else {
buffer.get(); // unknown (always 0x00)
} }
byte type = buffer.get(); byte type = buffer.get();
int steps = buffer.getInt(); int steps = buffer.getInt();
@@ -79,15 +82,20 @@ public class MoyoungWorkoutSummaryParser implements ActivitySummaryParser {
int calories; int calories;
if (protocolVersion == 1) { if (protocolVersion == 1) {
calories = buffer.getShort(); calories = buffer.getShort();
} else { } else if (protocolVersion == 2) {
calories = buffer.getInt(); calories = buffer.getInt();
} else {
calories = buffer.getShort();
avgHR = buffer.get();
buffer.get(); // 0?
// todo last 4 bytes?
} }
summaryData.add(ActivitySummaryEntries.ACTIVE_SECONDS, validTime, ActivitySummaryEntries.UNIT_SECONDS); summaryData.add(ActivitySummaryEntries.ACTIVE_SECONDS, validTime, ActivitySummaryEntries.UNIT_SECONDS);
summaryData.add(ActivitySummaryEntries.STEPS, steps, ActivitySummaryEntries.UNIT_STEPS); summaryData.add(ActivitySummaryEntries.STEPS, steps, ActivitySummaryEntries.UNIT_STEPS);
summaryData.add(ActivitySummaryEntries.DISTANCE_METERS, distance, ActivitySummaryEntries.UNIT_METERS); summaryData.add(ActivitySummaryEntries.DISTANCE_METERS, distance, ActivitySummaryEntries.UNIT_METERS);
summaryData.add(ActivitySummaryEntries.CALORIES_BURNT, calories, ActivitySummaryEntries.UNIT_KCAL); summaryData.add(ActivitySummaryEntries.CALORIES_BURNT, calories, ActivitySummaryEntries.UNIT_KCAL);
if (protocolVersion == 2) { if (avgHR > 0) {
summaryData.add(ActivitySummaryEntries.HR_AVG, avgHR, ActivitySummaryEntries.UNIT_BPM); summaryData.add(ActivitySummaryEntries.HR_AVG, avgHR, ActivitySummaryEntries.UNIT_BPM);
} }
@@ -116,6 +116,15 @@ public class MoyoungActivitySampleProvider extends AbstractSampleProvider<Moyoun
return MoyoungConstants.WORKOUT_TYPES_TO_ACTIVITY_KIND.getOrDefault((byte) rawType, ActivityKind.ACTIVITY); return MoyoungConstants.WORKOUT_TYPES_TO_ACTIVITY_KIND.getOrDefault((byte) rawType, ActivityKind.ACTIVITY);
} }
// FIXME we should actually persist the raw kinds...
public ActivityKind normalizeTypeV3(int rawType) {
return switch (rawType) {
case 13 -> ActivityKind.WALKING; // gps on foot?
case 14 -> ActivityKind.WALKING;
default -> ActivityKind.UNKNOWN;
};
}
@Override @Override
public int toRawActivityKind(ActivityKind activityKind) { public int toRawActivityKind(ActivityKind activityKind) {
if (activityKind == ActivityKind.NOT_MEASURED) if (activityKind == ActivityKind.NOT_MEASURED)
@@ -1339,26 +1339,33 @@ public class MoyoungDeviceSupport extends AbstractBTLESingleDeviceSupport {
} }
public void handleTrainingData(byte[] data) { public void handleTrainingData(byte[] data) {
int protocolVersion = 0; final int protocolVersion;
int trainingBytesLength = 24; final int trainingBytesLength;
if (data.length % 24 == 0) { if (data.length % 24 == 0) {
protocolVersion = 1; protocolVersion = 1;
trainingBytesLength = 24;
} else if (data.length % 26 == 0) { } else if (data.length % 26 == 0) {
protocolVersion = 2; protocolVersion = 2;
trainingBytesLength = 26; trainingBytesLength = 26;
} } else if (data.length % 30 == 0) {
if (protocolVersion == 0) { protocolVersion = 3;
LOG.error("Invalid training data received"); trainingBytesLength = 30;
} else {
LOG.error("Invalid training data received of length {}", data.length);
return; return;
} }
for (int i = 0; i < data.length / trainingBytesLength; i++) { for (int i = 0; i < data.length / trainingBytesLength; i++) {
if (protocolVersion == 1 && ArrayUtils.isAllZeros(data, trainingBytesLength * i, trainingBytesLength)) { if (protocolVersion == 1 && ArrayUtils.isAllZeros(data, trainingBytesLength * i, trainingBytesLength)) {
LOG.info("Skipping empty workout details packet"); LOG.info("Skipping empty workout details packet v1");
continue; continue;
} }
if (protocolVersion == 2 && ArrayUtils.isAllZeros(data, 2, trainingBytesLength - 2)) { if (protocolVersion == 2 && ArrayUtils.isAllZeros(data, 2, trainingBytesLength - 2)) {
LOG.info("Skipping empty workout details packet"); LOG.info("Skipping empty workout details packet v2");
continue;
}
if (protocolVersion == 3 && ArrayUtils.isAllZeros(data, trainingBytesLength * i + 2, 24)) {
LOG.info("Skipping empty workout details packet v3");
continue; continue;
} }
@@ -1366,7 +1373,7 @@ public class MoyoungDeviceSupport extends AbstractBTLESingleDeviceSupport {
buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.order(ByteOrder.LITTLE_ENDIAN);
byte num = 0; byte num = 0;
byte avgHR = 0; byte avgHR = 0;
if (protocolVersion == 2) { if (protocolVersion == 2 || protocolVersion == 3) {
buffer.get(); // skip packet subtype buffer.get(); // skip packet subtype
num = buffer.get(); num = buffer.get();
} }
@@ -1375,8 +1382,10 @@ public class MoyoungDeviceSupport extends AbstractBTLESingleDeviceSupport {
int validTime = buffer.getShort(); int validTime = buffer.getShort();
if (protocolVersion == 1) { if (protocolVersion == 1) {
num = buffer.get(); // == i num = buffer.get(); // == i
} else { } else if (protocolVersion == 2) {
avgHR = buffer.get(); avgHR = buffer.get();
} else {
buffer.get(); // unknown (always 0x00)
} }
byte type = buffer.get(); byte type = buffer.get();
int steps = buffer.getInt(); int steps = buffer.getInt();
@@ -1384,8 +1393,13 @@ public class MoyoungDeviceSupport extends AbstractBTLESingleDeviceSupport {
int calories; int calories;
if (protocolVersion == 1) { if (protocolVersion == 1) {
calories = buffer.getShort(); calories = buffer.getShort();
} else { } else if (protocolVersion == 2) {
calories = buffer.getInt(); calories = buffer.getInt();
} else {
calories = buffer.getShort();
avgHR = buffer.get();
buffer.get(); // 0?
// todo last 4 bytes?
} }
LOG.info("Training data: start={} end={} totalTimeWithoutPause={} num={} type={} steps={} avgHR={} distance={} calories={}", startTime, endTime, validTime, num, type, steps, avgHR, distance, calories); LOG.info("Training data: start={} end={} totalTimeWithoutPause={} num={} type={} steps={} avgHR={} distance={} calories={}", startTime, endTime, validTime, num, type, steps, avgHR, distance, calories);
@@ -1415,8 +1429,8 @@ public class MoyoungDeviceSupport extends AbstractBTLESingleDeviceSupport {
summary.setDevice(device); summary.setDevice(device);
summary.setUser(user); summary.setUser(user);
ActivityKind gbType = provider.normalizeType(type); ActivityKind gbType = protocolVersion < 3 ? provider.normalizeType(type) : provider.normalizeTypeV3(type);
summary.setName(gbType.name()); summary.setName(gbType.getLabel(getContext()));
summary.setActivityKind(gbType.getCode()); summary.setActivityKind(gbType.getCode());
summary.setStartTime(startTime); summary.setStartTime(startTime);
@@ -0,0 +1,50 @@
package nodomain.freeyourgadget.gadgetbridge.devices.moyoung;
import org.junit.Test;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class MoyoungWorkoutSummaryParserTest extends TestBase {
@Test
public void testParseWorkout1() {
// #6053 - 00:36:34, 2.3km, 163kcal, 15'41'', 2767 steps, 76 spm, 89bpm
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(GB.hexStringToByteArray("030D257C126AB984126A92080022CF0A00001A090000A3005900A9C1C440"));
new MoyoungWorkoutSummaryParser(null).parseBinaryData(summary, false);
final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summary.getSummaryData());
assertNotNull(summaryData);
assertEquals((double) (36 * 60 + 34), summaryData.getNumber(ActivitySummaryEntries.ACTIVE_SECONDS, -1));
assertEquals(2330d, summaryData.getNumber(ActivitySummaryEntries.DISTANCE_METERS, -1));
assertEquals(163d, summaryData.getNumber(ActivitySummaryEntries.CALORIES_BURNT, -1));
assertEquals(2767d, summaryData.getNumber(ActivitySummaryEntries.STEPS, -1));
assertEquals(89d, summaryData.getNumber(ActivitySummaryEntries.HR_AVG, -1));
}
@Test
public void testParseWorkout2() {
// #6053 - 00:32:01, 1.7km, 122kcal, 18'20'', 2011 steps, 62 spm, 93bpm
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(GB.hexStringToByteArray("030E9BAF136A1CB7136A8107001EDB070000D20600007A005D003DB8C740"));
new MoyoungWorkoutSummaryParser(null).parseBinaryData(summary, false);
final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summary.getSummaryData());
assertNotNull(summaryData);
assertEquals((double) (32 * 60 + 1), summaryData.getNumber(ActivitySummaryEntries.ACTIVE_SECONDS, -1));
assertEquals(1746d, summaryData.getNumber(ActivitySummaryEntries.DISTANCE_METERS, -1));
assertEquals(122d, summaryData.getNumber(ActivitySummaryEntries.CALORIES_BURNT, -1));
assertEquals(2011d, summaryData.getNumber(ActivitySummaryEntries.STEPS, -1));
assertEquals(93d, summaryData.getNumber(ActivitySummaryEntries.HR_AVG, -1));
}
}
@@ -0,0 +1,65 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.moyoung;
import android.util.Pair;
import junit.framework.TestCase;
import org.junit.Test;
import nodomain.freeyourgadget.gadgetbridge.test.TestBase;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class MoyoungDeviceSupportTest extends TestBase {
@Test
public void testWorkoutColumnV76() {
final MoyoungDeviceSupport support = new MoyoungDeviceSupport();
// #6053
final byte[][] packets = new byte[][] {
GB.hexStringToByteArray("FEEA2023B2030000000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030100000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030200000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030300000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030400000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030500000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030600000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030700000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030800000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030900000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030A00000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030B00000000000000000000000000000000000000000000000000000000"),
GB.hexStringToByteArray("FEEA2023B2030C00000000000000000000000000000000000000000000000052B8CB41"),
// 00:36:34
// 2.3km
// 163kcal
// 15'41"
// 2767 steps
// 76 steps/min
// 80bpm
GB.hexStringToByteArray("FEEA2023B2030D257C126AB984126A92080022CF0A00001A090000A3005900A9C1C440"),
// 00:32:01
// 1.7km
// 122kcal
// 18'20"
// GPS trace
// 2011 steps
// 62 steps/min
// 93bpm
GB.hexStringToByteArray("FEEA2023B2030E9BAF136A1CB7136A8107001EDB070000D20600007A005D003DB8C740")
};
MoyoungPacketIn packetIn = new MoyoungPacketIn();
for (byte[] value : packets) {
if (packetIn.putFragment(value)) {
Pair<Byte, byte[]> packet = MoyoungPacketIn.parsePacket(packetIn.getPacket());
packetIn = new MoyoungPacketIn();
if (packet != null) {
byte packetType = packet.first;
byte[] payload = packet.second;
support.handleTrainingData(payload);
}
}
}
}
}