mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
CMF Watch Pro 3: Workout sync
This commit is contained in:
+12
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+96
-3
@@ -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());
|
||||
|
||||
+17
-9
@@ -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,12 +431,10 @@ public class CmfActivitySync {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: This should be set by CmfWorkoutSummaryParser
|
||||
if (summaryBytes[30] == 1) {
|
||||
// Assume all activities have GPS
|
||||
activitiesWithGps.add(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleWorkoutGps(final byte[] payload) {
|
||||
if (payload.length % 12 != 0) {
|
||||
@@ -495,9 +498,14 @@ public class CmfActivitySync {
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
return mSupport.getContext();
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+24
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user