CMF Watch Pro 3: Workout sync

This commit is contained in:
José Rebelo
2026-03-21 22:43:17 +00:00
parent cb435e067a
commit f6455f777c
6 changed files with 154 additions and 20 deletions
@@ -16,10 +16,16 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro;
import android.content.Context;
import androidx.annotation.Nullable;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
public class CmfWatchPro3Coordinator extends CmfWatchProCoordinator {
@Override
@@ -32,6 +38,12 @@ public class CmfWatchPro3Coordinator extends CmfWatchProCoordinator {
return R.string.devicetype_nothing_cmf_watch_pro_3;
}
@Nullable
@Override
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
return new CmfWorkoutSummaryParser(device, context, 3);
}
@Override
public int getDefaultIconResource() {
return R.drawable.ic_device_watchxplus;
@@ -181,7 +181,7 @@ public class CmfWatchProCoordinator extends AbstractBLEDeviceCoordinator {
@Nullable
@Override
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
return new CmfWorkoutSummaryParser(device, context);
return new CmfWorkoutSummaryParser(device, context, 1);
}
@Nullable
@@ -18,6 +18,7 @@ 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.CADENCE_AVG;
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;
@@ -27,16 +28,21 @@ import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.
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.PACE_AVG_SECONDS_KM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.PACE_MAX;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.RECOVERY_TIME;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STEPS;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.STRIDE_AVG;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_EFFECT_AEROBIC;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.TRAINING_EFFECT_ANAEROBIC;
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_CM;
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_SECONDS_PER_KM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SPM;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_STEPS;
import android.content.Context;
@@ -64,14 +70,29 @@ public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
private final GBDevice gbDevice;
private final Context context;
private final int version;
public CmfWorkoutSummaryParser(final GBDevice device, final Context context) {
public CmfWorkoutSummaryParser(final GBDevice device,
final Context context,
final int version) {
this.gbDevice = device;
this.context = context;
this.version = version;
}
@Override
public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary, final boolean forDetails) {
switch (version) {
case 1:
return parseBinaryDataV1(summary);
case 3:
return parseBinaryDataV3(summary);
}
throw new IllegalArgumentException("Unknown version " + version);
}
private BaseActivitySummary parseBinaryDataV1(final BaseActivitySummary summary) {
final byte[] rawSummaryData = summary.getRawSummaryData();
if (rawSummaryData == null) {
return summary;
@@ -111,7 +132,7 @@ public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
final int averagePace = buf.getShort();
summaryData.add(PACE_AVG_SECONDS_KM, averagePace, UNIT_SECONDS);
summaryData.add(PACE_AVG_SECONDS_KM, averagePace, UNIT_SECONDS_PER_KM);
buf.get(new byte[2]); //?
buf.get(new byte[4]); //?
@@ -170,7 +191,79 @@ public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
summaryData.add(ACTIVE_SCORE, Math.round(activityScore / 1000f), UNIT_NONE);
}
} catch (final Exception e) {
LOG.error("Failed to parse workout binary data", e);
LOG.error("Failed to parse workout binary data v1", e);
}
summary.setSummaryData(summaryData.toString());
return summary;
}
private BaseActivitySummary parseBinaryDataV3(final BaseActivitySummary summary) {
final byte[] rawSummaryData = summary.getRawSummaryData();
if (rawSummaryData == null) {
return summary;
}
final ByteBuffer buf = ByteBuffer.wrap(rawSummaryData).order(ByteOrder.LITTLE_ENDIAN);
final ActivitySummaryData summaryData = new ActivitySummaryData();
// We should be able to get at least the first few right
final int startTime = buf.getInt();
summary.setStartTime(new Date(startTime * 1000L));
final int endTime = buf.getInt();
summary.setEndTime(new Date(endTime * 1000L));
final int duration = buf.getInt();
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());
} else {
summary.setActivityKind(ActivityKind.UNKNOWN.getCode());
}
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);
if (buf.remaining() > 40) {
// TODO not sure, but it's the only sample I have with gps
summaryData.setHasGps(true);
buf.get(new byte[40]); //?
final int steps = buf.getInt();
summaryData.add(STEPS, steps, UNIT_STEPS);
final int distanceMeters = buf.getInt();
summaryData.add(DISTANCE_METERS, distanceMeters, UNIT_METERS);
buf.getInt(); // ?
final int maxPace = buf.getInt();
summaryData.add(PACE_MAX, maxPace, UNIT_SECONDS_PER_KM);
final int avgPace = buf.getInt();
summaryData.add(PACE_AVG_SECONDS_KM, avgPace, UNIT_SECONDS_PER_KM);
buf.getInt(); // ?
final int cadence = buf.getInt(); // steps per min
summaryData.add(CADENCE_AVG, cadence, UNIT_SPM);
final int stride = buf.getInt(); // stride length
summaryData.add(STRIDE_AVG, stride, UNIT_CM);
}
} catch (final Exception e) {
LOG.error("Failed to parse workout binary data v3", e);
}
summary.setSummaryData(summaryData.toString());
@@ -96,7 +96,10 @@ public class CmfActivitySync {
handleSpo2(payload);
return true;
case WORKOUT_SUMMARY:
handleWorkoutSummary(payload);
handleWorkoutSummary(payload, 1);
return true;
case WORKOUT_SUMMARY_V3:
handleWorkoutSummary(payload, 3);
return true;
case WORKOUT_GPS:
handleWorkoutGps(payload);
@@ -369,23 +372,25 @@ public class CmfActivitySync {
}
}
private void handleWorkoutSummary(final byte[] payload) {
private void handleWorkoutSummary(final byte[] payload, final int version) {
final int bytesPerWorkout;
if (payload.length % 32 == 0) {
if (version == 3) {
bytesPerWorkout = payload.length;
} else if (payload.length % 32 == 0) {
bytesPerWorkout = 32;
} else if (payload.length % 54 == 0) {
bytesPerWorkout = 54;
} else {
LOG.error("Workout summary payload size {} not divisible by 32 or 54", payload.length);
LOG.error("Workout summary payload size {} not divisible by 32/54", payload.length);
return;
}
LOG.debug("Got {} workout summary samples", payload.length / bytesPerWorkout);
LOG.debug("Got {} workout summary samples for version {}", payload.length / bytesPerWorkout, version);
final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice(), getContext());
final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice(), getContext(), version);
while (buf.remaining() > 0) {
final byte[] summaryBytes = new byte[bytesPerWorkout];
@@ -426,10 +431,8 @@ public class CmfActivitySync {
return;
}
// FIXME: This should be set by CmfWorkoutSummaryParser
if (summaryBytes[30] == 1) {
activitiesWithGps.add(summary);
}
// Assume all activities have GPS
activitiesWithGps.add(summary);
}
}
@@ -495,8 +498,13 @@ public class CmfActivitySync {
return;
}
// Save the gpx file
AutoGpxExporter.doExport(getContext(), getDevice(), summary, activityTrack);
final boolean hasGps = activityTrack.getAllPoints().stream()
.anyMatch(p -> p.getLocation() != null);
if (hasGps) {
// Save the gpx file
AutoGpxExporter.doExport(getContext(), getDevice(), summary, activityTrack);
}
}
private Context getContext() {
@@ -103,6 +103,7 @@ public enum CmfCommand {
WEATHER_SET_2(0x0066, 0x0001),
WORKOUT_GPS(0xffff, 0xa05a),
WORKOUT_SUMMARY(0x0057, 0x0001),
WORKOUT_SUMMARY_V3(0x0160, 0x0001),
;
private final int cmd1;
@@ -14,11 +14,11 @@ import static org.junit.Assert.assertNotNull;
public class CmfWorkoutSummaryParserTest extends TestBase {
@Test
public void testParseSummary2() {
public void testParseSummary_v1_2() {
// From https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4530
final byte[] bytes = GB.hexStringToByteArray("9F7734685D02039D8B0008060000FA0500007B01000000000000057A346800001B0064000000B40001000100000006000000401F0000");
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext());
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext(), 1);
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(bytes);
parser.parseBinaryData(summary, false);
@@ -60,11 +60,11 @@ public class CmfWorkoutSummaryParserTest extends TestBase {
}
@Test
public void testParseSummary3() {
public void testParseSummary_v1_3() {
// From https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4530
final byte[] bytes = GB.hexStringToByteArray("ec153769380e02afe8039d250000e222000097010000000000002524376901006400e000f401310d0000000001002b000e00281d0100");
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext());
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext(), 1);
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(bytes);
parser.parseBinaryData(summary, false);
@@ -107,4 +107,24 @@ public class CmfWorkoutSummaryParserTest extends TestBase {
assertEquals(56 * 60 * 60d + 17 * 60d, summaryData.getNumber(ActivitySummaryEntries.RECOVERY_TIME, -1)); // 56.3h
assertEquals(73d, summaryData.getNumber(ActivitySummaryEntries.ACTIVE_SCORE, -1)); // +73
}
@Test
public void testParseSummary_v3_1() {
// From https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4530
final byte[] bytes = GB.hexStringToByteArray("f3459269ad469269b8000000134b0100000000000a0000000000000000000000000000000000000000000302000000000000000000000000");
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext(), 3);
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(bytes);
parser.parseBinaryData(summary, true);
}
@Test
public void testParseSummary_v3_2() {
// From https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4530
final byte[] bytes = GB.hexStringToByteArray("70A39369C6A3936954000000135C0200000000000A0000000000000000000000000000004401000000000306000000000000000000000000");
final CmfWorkoutSummaryParser parser = new CmfWorkoutSummaryParser(null, getContext(), 3);
final BaseActivitySummary summary = new BaseActivitySummary();
summary.setRawSummaryData(bytes);
parser.parseBinaryData(summary, true);
}
}