CMF Watch Pro 2: Improve activity parsing (#4530)

This commit is contained in:
José Rebelo
2025-06-07 13:25:31 +01:00
parent c7279d3cf2
commit e54fb0cf4d
6 changed files with 173 additions and 15 deletions
@@ -192,7 +192,7 @@ public class CmfWatchProCoordinator extends AbstractBLEDeviceCoordinator {
@Nullable
@Override
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
return new CmfWorkoutSummaryParser(device);
return new CmfWorkoutSummaryParser(device, context);
}
@Override
@@ -1,4 +1,4 @@
/* Copyright (C) 2024 José Rebelo
/* Copyright (C) 2024-2025 José Rebelo
This file is part of Gadgetbridge.
@@ -17,12 +17,38 @@
package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SCORE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CALORIES_BURNT;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DISTANCE_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_AEROBIC;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_ANAEROBIC;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_FAT_BURN;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_MAXIMUM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.HR_ZONE_WARM_UP;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.RECOVERY_TIME;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STEPS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_LOAD;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_BPM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KCAL;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_NONE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_STEPS;
import android.content.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryProgressEntry;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
@@ -31,10 +57,14 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfActivityType;
public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
private final GBDevice gbDevice;
private static final Logger LOG = LoggerFactory.getLogger(CmfWorkoutSummaryParser.class);
public CmfWorkoutSummaryParser(final GBDevice device) {
private final GBDevice gbDevice;
private final Context context;
public CmfWorkoutSummaryParser(final GBDevice device, final Context context) {
this.gbDevice = device;
this.context = context;
}
@Override
@@ -48,18 +78,14 @@ public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
final ActivitySummaryData summaryData = new ActivitySummaryData();
// We should be able to get at least the first 3 right
final int startTime = buf.getInt();
final int duration = buf.getShort();
final byte workoutType = buf.get();
buf.get(new byte[19]); // ?
final int endTime = buf.getInt();
final boolean gps = buf.get() == 1;
buf.get(); // ?
summary.setStartTime(new Date(startTime * 1000L));
summary.setEndTime(new Date(endTime * 1000L));
final int duration = buf.getShort();
summaryData.add(ACTIVE_SECONDS, duration, UNIT_SECONDS);
final byte workoutType = buf.get();
final CmfActivityType cmfActivityType = CmfActivityType.fromCode(workoutType);
if (cmfActivityType != null) {
summary.setActivityKind(cmfActivityType.getActivityKind().getCode());
@@ -67,7 +93,76 @@ public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
summary.setActivityKind(ActivityKind.UNKNOWN.getCode());
}
summaryData.add(ACTIVE_SECONDS, duration, UNIT_SECONDS);
try {
final int hrAvg = buf.get() & 0xff;
summaryData.add(HR_AVG, hrAvg, UNIT_BPM);
final int calories = buf.getShort();
summaryData.add(CALORIES_BURNT, calories, UNIT_KCAL);
final int steps = buf.getInt();
summaryData.add(STEPS, steps, UNIT_STEPS);
final int distanceMeters = buf.getInt();
summaryData.add(DISTANCE_METERS, distanceMeters, UNIT_METERS);
buf.get(new byte[8]); //?
final int endTime = buf.getInt();
summary.setEndTime(new Date(endTime * 1000L));
final boolean gps = buf.get() == 1;
buf.get(); // ?
if (buf.position() < buf.limit()) {
// Watch 2 has more information
final int trainingLoad = buf.getShort();
summaryData.add(TRAINING_LOAD, trainingLoad, UNIT_NONE);
buf.get(new byte[4]); //?
final int recoveryTimeMinutes = buf.getShort();
summaryData.add(RECOVERY_TIME, recoveryTimeMinutes * 60, UNIT_SECONDS);
final List<String> zones = Arrays.asList(HR_ZONE_WARM_UP, HR_ZONE_FAT_BURN, HR_ZONE_AEROBIC, HR_ZONE_ANAEROBIC, HR_ZONE_MAXIMUM);
final int[] zoneTimes = new int[zones.size()];
zoneTimes[0] = buf.getShort() * 60;
zoneTimes[1] = buf.getShort() * 60;
zoneTimes[2] = buf.getShort() * 60;
zoneTimes[3] = buf.getShort() * 60;
zoneTimes[4] = buf.getShort() * 60;
final float totalTimeInZone = Arrays.stream(zoneTimes).asLongStream().sum();
if (totalTimeInZone > 0) {
final int[] zoneColors = new int[]{
context.getResources().getColor(R.color.hr_zone_warm_up_color),
context.getResources().getColor(R.color.hr_zone_easy_color),
context.getResources().getColor(R.color.hr_zone_aerobic_color),
context.getResources().getColor(R.color.hr_zone_threshold_color),
context.getResources().getColor(R.color.hr_zone_maximum_color),
};
for (int i = 0; i < zones.size(); i++) {
summaryData.add(
zones.get(i),
new ActivitySummaryProgressEntry(
zoneTimes[i],
UNIT_SECONDS,
(int) ((100 * zoneTimes[i]) / totalTimeInZone),
zoneColors[i]
)
);
}
}
final int activityScore = buf.getShort();
summaryData.add(ACTIVE_SCORE, Math.round(activityScore / 1000f), UNIT_NONE);
}
} catch (final Exception e) {
LOG.error("Failed to parse workout binary data", e);
}
summary.setSummaryData(summaryData.toString());
return summary;
}
@@ -93,6 +93,7 @@ public class ActivitySummaryEntries {
public static final String AVG_TORQUE_EFFECTIVENESS = "average_torque_effectiveness";
public static final String FRONT_GEAR_SHIFTS = "front_gear_shifts";
public static final String REAR_GEAR_SHIFTS = "rear_gear_shifts";
public static final String ACTIVE_SCORE = "workout_active_score";
public static final String AVG_VERTICAL_OSCILLATION = "vertical_oscillation";
public static final String AVG_GROUND_CONTACT_TIME = "ground_contact_time";
@@ -397,7 +397,7 @@ public class CmfActivitySync {
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice());
final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice(), getContext());
while (buf.remaining() > 0) {
final byte[] summaryBytes = new byte[bytesPerWorkout];
+1
View File
@@ -2552,6 +2552,7 @@
<string name="average_torque_effectiveness">Torque Effectiveness</string>
<string name="front_gear_shifts">Front shifts</string>
<string name="rear_gear_shifts">Rear shifts</string>
<string name="workout_active_score">Active Score</string>
<string name="vertical_oscillation">Vertical Oscillation</string>
<string name="ground_contact_time">Ground Contact Time</string>
<string name="vertical_ratio">Vertical Ratio</string>
@@ -0,0 +1,61 @@
package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout;
import org.junit.Test;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
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 CmfWorkoutSummaryParserTest extends TestBase {
@Test
public void testParseSummary2() {
// From https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4530
final byte[] bytes = GB.hexStringToByteArray("9F7734685D02039D8B0008060000FA0500007B01000000000000057A346800001B0064000000B40001000100000006000000401F0000");
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext());
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(bytes);
parser.parseBinaryData(summary, false);
assertEquals(1748268959000L, summary.getStartTime().getTime());
assertEquals(1748269573000L, summary.getEndTime().getTime());
assertEquals(ActivityKind.INDOOR_RUNNING.getCode(), summary.getActivityKind());
final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summary.getSummaryData());
assertNotNull(summaryData);
assertEquals(1530d, summaryData.getNumber(ActivitySummaryEntries.DISTANCE_METERS, -1));
assertEquals(605d, summaryData.getNumber(ActivitySummaryEntries.ACTIVE_SECONDS, -1));
assertEquals(139d, summaryData.getNumber(ActivitySummaryEntries.CALORIES_BURNT, -1));
// TODO max pace 6'23''
// TODO avg speed 6.50kmph
// TODO avg pace 6'19"
assertEquals(1544d, summaryData.getNumber(ActivitySummaryEntries.STEPS, -1));
// TODO avg step rate 151 steps/min
// TODO avg step stride 102cm
assertEquals(157d, summaryData.getNumber(ActivitySummaryEntries.HR_AVG, -1));
// TODO max hr 190
assertEquals(1 * 60d, summaryData.getNumber(ActivitySummaryEntries.HR_ZONE_WARM_UP, -1)); // 1 min
assertEquals(1 * 60d, summaryData.getNumber(ActivitySummaryEntries.HR_ZONE_FAT_BURN, -1)); // 1 min
assertEquals(0d, summaryData.getNumber(ActivitySummaryEntries.HR_ZONE_AEROBIC, -1)); // 0 min
assertEquals(6 * 60d, summaryData.getNumber(ActivitySummaryEntries.HR_ZONE_ANAEROBIC, -1)); // 6 min
// TODO aerobic endurance 1.0 / relaxed
assertEquals(27d, summaryData.getNumber(ActivitySummaryEntries.TRAINING_LOAD, -1));
assertEquals(3 * 60 * 60d, summaryData.getNumber(ActivitySummaryEntries.RECOVERY_TIME, -1)); // 3h
assertEquals(8d, summaryData.getNumber(ActivitySummaryEntries.ACTIVE_SCORE, -1)); // +8
}
}