Zepp OS: Parse workout pace, cadence, strength sets

This commit is contained in:
José Rebelo
2025-04-25 23:46:08 +01:00
parent b378a51656
commit 04f4b2f448
6 changed files with 202 additions and 13 deletions
@@ -0,0 +1,79 @@
package nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
public class ActivitySummaryTableBuilder {
private final String group;
private final String headerId;
private final List<String> headerColumns;
private final Map<String, List<ActivitySummaryValue>> rows = new LinkedHashMap<>();
public ActivitySummaryTableBuilder(final String group, final String headerId, final List<String> headerColumns) {
this.group = group;
this.headerId = headerId;
this.headerColumns = headerColumns;
}
public void addRow(final String rowId, final List<ActivitySummaryValue> row) {
if (row.size() != headerColumns.size()) {
throw new IllegalArgumentException("Invalid number of row columns " + row.size());
}
rows.put(rowId, row);
}
public void addToSummaryData(final ActivitySummaryData summaryData) {
final int numColumns = headerColumns.size();
final boolean[] anyNonNull = new boolean[numColumns];
for (final List<ActivitySummaryValue> row : rows.values()) {
for (int i = 0; i < numColumns; i++) {
anyNonNull[i] |= row.get(i) != null;
}
}
final List<ActivitySummaryValue> finalHeader = new ArrayList<>(numColumns);
for (int i = 0; i < numColumns; i++) {
if (anyNonNull[i]) {
finalHeader.add(new ActivitySummaryValue(headerColumns.get(i)));
}
}
summaryData.add(
headerId,
new ActivitySummaryTableRowEntry(
group,
finalHeader,
true,
true
)
);
for (final Map.Entry<String, List<ActivitySummaryValue>> e : rows.entrySet()) {
final String key = e.getKey();
final List<ActivitySummaryValue> row = e.getValue();
final List<ActivitySummaryValue> finalRow = new ArrayList<>(numColumns);
for (int i = 0; i < numColumns; i++) {
if (anyNonNull[i]) {
finalRow.add(row.get(i));
}
}
summaryData.add(
key,
new ActivitySummaryTableRowEntry(
group,
finalRow,
false,
true
)
);
}
}
}
@@ -47,7 +47,7 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser {
return null;
}
summaryData = new ActivitySummaryData();
parseBinaryData(summary, startTime);
parseBinaryData(summary, startTime, forDetails);
summary.setSummaryData(summaryData.toString());
return summary;
}
@@ -56,7 +56,7 @@ public class HuamiActivitySummaryParser implements ActivitySummaryParser {
return new HuamiActivityDetailsParser(summary);
}
protected void parseBinaryData(BaseActivitySummary summary, Date startTime) {
protected void parseBinaryData(BaseActivitySummary summary, Date startTime, final boolean forDetails) {
final byte[] rawSummaryData = summary.getRawSummaryData();
if (rawSummaryData == null) {
return;
@@ -23,22 +23,32 @@ import android.content.Context;
import com.google.protobuf.InvalidProtocolBufferException;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryProgressEntry;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryTableBuilder;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryValue;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.proto.HuamiProtos;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsActivityDetailsParser;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsActivityType;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
public class ZeppOsActivitySummaryParser extends HuamiActivitySummaryParser {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsActivitySummaryParser.class);
@@ -54,7 +64,7 @@ public class ZeppOsActivitySummaryParser extends HuamiActivitySummaryParser {
}
@Override
protected void parseBinaryData(final BaseActivitySummary summary, final Date startTime) {
protected void parseBinaryData(final BaseActivitySummary summary, final Date startTime, final boolean forDetails) {
final byte[] rawData = summary.getRawSummaryData();
if (rawData == null) {
return;
@@ -208,5 +218,52 @@ public class ZeppOsActivitySummaryParser extends HuamiActivitySummaryParser {
summaryData.add(STROKE_DISTANCE_AVG, summaryProto.getSwimmingData().getAvgDps(), UNIT_CM);
summaryData.add(SWOLF_INDEX, summaryProto.getSwimmingData().getSwolf(), UNIT_NONE);
}
if (forDetails && !StringUtils.isBlank(summary.getRawDetailsPath())) {
try {
enrichWithDetails(summary);
} catch (final Exception e) {
LOG.error("Failed enrich summary", e);
}
}
}
private void enrichWithDetails(final BaseActivitySummary summary) throws IOException, GBException {
final File inputFile = FileUtils.tryFixPath(new File(summary.getRawDetailsPath()));
if (inputFile == null) {
return;
}
final byte[] detailsBytes;
try (InputStream inputStream = new FileInputStream(inputFile)) {
detailsBytes = FileUtils.readAll(inputStream, inputFile.length());
}
final ZeppOsActivityDetailsParser detailsParser = new ZeppOsActivityDetailsParser(summary);
final ZeppOsActivityTrack activityTrack = detailsParser.parse(detailsBytes);
List<ZeppOsActivityTrack.StrengthSet> strengthSets = activityTrack.getStrengthSets();
if (!strengthSets.isEmpty()) {
final ActivitySummaryTableBuilder tableBuilder = new ActivitySummaryTableBuilder(SETS, "sets_header", Arrays.asList(
"set",
"workout_set_reps",
"menuitem_weight"
));
int i = 1;
for (final ZeppOsActivityTrack.StrengthSet strengthSet : strengthSets) {
tableBuilder.addRow(
"set_" + i,
Arrays.asList(
new ActivitySummaryValue(i, UNIT_NONE),
new ActivitySummaryValue(String.valueOf(strengthSet.getReps())),
new ActivitySummaryValue(strengthSet.getWeightKg() >= 0 ? strengthSet.getWeightKg() : null, UNIT_KG)
)
);
i++;
}
tableBuilder.addToSummaryData(summaryData);
}
}
}
@@ -79,7 +79,7 @@ public class FetchSportsSummaryOperation extends AbstractFetchOperation {
summary.setStartTime(getLastStartTimestamp().getTime()); // due to a bug this has to be set
summary.setRawSummaryData(buffer.toByteArray());
try {
summary = summaryParser.parseBinaryData(summary, true);
summary = summaryParser.parseBinaryData(summary, false);
} catch (final Exception e) {
GB.toast(getContext(), "Failed to parse activity summary", Toast.LENGTH_LONG, GB.ERROR, e);
return false;
@@ -29,12 +29,12 @@ import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiActivityDetailsParser;
@@ -54,7 +54,7 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
private long latitude;
private double altitude;
private final ActivityTrack activityTrack;
private final ZeppOsActivityTrack activityTrack;
private ActivityPoint lastActivityPoint;
public ZeppOsActivityDetailsParser(final BaseActivitySummary summary) {
@@ -64,14 +64,14 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
this.latitude = summary.getBaseLatitude();
this.altitude = summary.getBaseAltitude();
this.activityTrack = new ActivityTrack();
this.activityTrack = new ZeppOsActivityTrack();
this.activityTrack.setUser(summary.getUser());
this.activityTrack.setDevice(summary.getDevice());
this.activityTrack.setName(createActivityName(summary));
}
@Override
public ActivityTrack parse(final byte[] bytes) throws GBException {
public ZeppOsActivityTrack parse(final byte[] bytes) throws GBException {
final ByteBuffer buf = ByteBuffer.wrap(bytes)
.order(ByteOrder.LITTLE_ENDIAN);
@@ -100,7 +100,7 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
unknownTypeCodes.put(typeCode, 0);
}
unknownTypeCodes.put(typeCode, unknownTypeCodes.get(typeCode) + 1);
unknownTypeCodes.put(typeCode, Objects.requireNonNull(unknownTypeCodes.get(typeCode)) + 1);
//LOG.warn("Unknown type code {} of length {}", String.format("0x%X", typeCode), length);
// Consume the reported length
buf.get(new byte[length]);
@@ -136,7 +136,8 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
consumeHeartRate(buf);
break;
case STRENGTH_SET:
// TODO parse strength sets: weight, count, type
consumeStrengthSet(buf);
break;
default:
LOG.warn("No consumer for for type {}", type);
// Consume the reported length
@@ -233,8 +234,6 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
LOG.warn("Unknown status code {}", String.format("0x%X", statusCode));
}
// TODO split track into multiple segments?
//trace("Consumed Status: {}", status);
}
@@ -245,7 +244,13 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
final short stride = buf.getShort(); // cm
final short pace = buf.getShort(); // sec/km
// TODO integrate into gpx
final ActivityPoint ap = getCurrentActivityPoint();
if (ap != null) {
ap.setCadence(cadence);
if (pace != 0) {
ap.setSpeed(1000f / pace); // s/km -> m/s
}
}
//trace("Consumed speed: cadence={}, stride={}, pace={}", cadence, stride, pace);
}
@@ -280,6 +285,18 @@ public class ZeppOsActivityDetailsParser extends AbstractHuamiActivityDetailsPar
//trace("Consumed HeartRate: {}", heartRate);
}
private void consumeStrengthSet(final ByteBuffer buf) {
buf.get(new byte[15]); // ?
final int reps = buf.getShort() & 0xffff;
buf.get(); // 0?
final int weight = buf.getShort() & 0xffff;
buf.get(new byte[14]); // ffff... ?
activityTrack.addStrengthSet(reps, weight != 0xffff ? weight / 10f : -1);
//trace("Consumed strength set: reps={}, weightKg={}", reps, weightKg);
}
@Nullable
private ActivityPoint getCurrentActivityPoint() {
if (lastActivityPoint == null) {
@@ -0,0 +1,36 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
public class ZeppOsActivityTrack extends ActivityTrack {
public final List<StrengthSet> strengthSets = new ArrayList<>();
public void addStrengthSet(final int reps, final float weightKg) {
strengthSets.add(new StrengthSet(reps, weightKg));
}
public List<StrengthSet> getStrengthSets() {
return strengthSets;
}
public static class StrengthSet {
private final int reps;
private final float weightKg;
public StrengthSet(final int reps, final float weightKg) {
this.reps = reps;
this.weightKg = weightKg;
}
public int getReps() {
return reps;
}
public float getWeightKg() {
return weightKg;
}
}
}