mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-11-28 21:06:50 +01:00
Xiaomi: Parse daily summary and workout gps tracks
This commit is contained in:
parent
9e2e3bbebc
commit
128aed005b
@ -229,7 +229,7 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator {
|
||||
|
||||
@Override
|
||||
public boolean supportsActivityTracks() {
|
||||
// TODO It does, but not yet fully working
|
||||
// TODO It does, but not yet fully working - only in Mi Band 8
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -245,25 +245,19 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator {
|
||||
|
||||
@Override
|
||||
public boolean supportsHeartRateStats() {
|
||||
// TODO does it?
|
||||
// TODO it does - see DailySummaryParser
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPai() {
|
||||
// TODO does it?
|
||||
// TODO it does - vitality score
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSleepRespiratoryRate() {
|
||||
// TODO does it?
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAlarmDescription(final GBDevice device) {
|
||||
// TODO does it?
|
||||
// TODO it does
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -73,6 +73,12 @@ public class MiBand8Coordinator extends XiaomiCoordinator {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsActivityTracks() {
|
||||
// FIXME still has some issues
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsMultipleWeatherLocations() {
|
||||
return true;
|
||||
|
@ -19,13 +19,9 @@ package nodomain.freeyourgadget.gadgetbridge.export;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
|
||||
|
||||
public interface ActivityTrackExporter {
|
||||
@NonNull
|
||||
String getDefaultFileName(@NonNull ActivityTrack track);
|
||||
|
||||
void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException;
|
||||
|
||||
class GPXTrackEmptyException extends Exception {
|
||||
|
@ -19,7 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.export;
|
||||
|
||||
import android.util.Xml;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.xmlpull.v1.XmlSerializer;
|
||||
@ -39,7 +38,6 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
|
||||
public class GPXExporter implements ActivityTrackExporter {
|
||||
private static final String NS_GPX_URI = "http://www.topografix.com/GPX/1/1";
|
||||
@ -56,12 +54,6 @@ public class GPXExporter implements ActivityTrackExporter {
|
||||
private boolean includeHeartRate = true;
|
||||
private boolean includeHeartRateOfNearestSample = true;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getDefaultFileName(@NonNull ActivityTrack track) {
|
||||
return FileUtils.makeValidFileName(track.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException {
|
||||
String encoding = StandardCharsets.UTF_8.name();
|
||||
|
@ -38,9 +38,7 @@ public class ActivityPoint {
|
||||
private Date time;
|
||||
private GPSCoordinate location;
|
||||
private int heartRate;
|
||||
private long speed4;
|
||||
private long speed5;
|
||||
private long speed6;
|
||||
private float speed = -1;
|
||||
|
||||
// e.g. to describe a pause during the activity
|
||||
private @Nullable String description;
|
||||
@ -85,27 +83,11 @@ public class ActivityPoint {
|
||||
this.heartRate = heartRate;
|
||||
}
|
||||
|
||||
public long getSpeed4() {
|
||||
return speed4;
|
||||
public float getSpeed() {
|
||||
return speed;
|
||||
}
|
||||
|
||||
public void setSpeed4(long speed4) {
|
||||
this.speed4 = speed4;
|
||||
}
|
||||
|
||||
public long getSpeed5() {
|
||||
return speed5;
|
||||
}
|
||||
|
||||
public void setSpeed5(long speed5) {
|
||||
this.speed5 = speed5;
|
||||
}
|
||||
|
||||
public long getSpeed6() {
|
||||
return speed6;
|
||||
}
|
||||
|
||||
public void setSpeed6(long speed6) {
|
||||
this.speed6 = speed6;
|
||||
public void setSpeed(float speed) {
|
||||
this.speed = speed;
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,9 @@ import java.io.OutputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.Queue;
|
||||
import java.util.TimeZone;
|
||||
|
||||
@ -47,7 +49,7 @@ public class XiaomiActivityFileFetcher {
|
||||
|
||||
private final XiaomiHealthService mHealthService;
|
||||
|
||||
private final Queue<XiaomiActivityFileId> mFetchQueue = new LinkedList<>();
|
||||
private final Queue<XiaomiActivityFileId> mFetchQueue = new PriorityQueue<>();
|
||||
private ByteArrayOutputStream mBuffer = new ByteArrayOutputStream();
|
||||
private boolean isFetching = false;
|
||||
|
||||
@ -124,15 +126,15 @@ public class XiaomiActivityFileFetcher {
|
||||
LOG.warn("Failed to parse {}", fileId);
|
||||
}
|
||||
} catch (final Exception ex) {
|
||||
LOG.error("addChunk(): failed to parse activity: ", ex);
|
||||
LOG.error("Exception while parsing " + fileId, ex);
|
||||
}
|
||||
|
||||
triggerNextFetch();
|
||||
}
|
||||
}
|
||||
|
||||
public void fetch(final XiaomiActivityFileId fileId) {
|
||||
mFetchQueue.add(fileId);
|
||||
public void fetch(final List<XiaomiActivityFileId> fileIds) {
|
||||
mFetchQueue.addAll(fileIds);
|
||||
if (!isFetching) {
|
||||
// Currently not fetching anything, fetch the next
|
||||
isFetching = true;
|
||||
@ -163,23 +165,12 @@ public class XiaomiActivityFileFetcher {
|
||||
}
|
||||
|
||||
protected void dumpBytesToExternalStorage(final XiaomiActivityFileId fileId, final byte[] bytes) {
|
||||
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
||||
|
||||
try {
|
||||
final File externalFilesDir = FileUtils.getExternalFilesDir();
|
||||
final File targetDir = new File(externalFilesDir, "rawFetchOperations");
|
||||
targetDir.mkdirs();
|
||||
|
||||
final String filename = String.format(
|
||||
Locale.ROOT, "xiaomi_%s_%02X_%02X_%02X_v%d.bin",
|
||||
sdf.format(fileId.getTimestamp()),
|
||||
fileId.getTypeCode(),
|
||||
fileId.getSubtypeCode(),
|
||||
fileId.getDetailTypeCode(),
|
||||
fileId.getVersion()
|
||||
);
|
||||
|
||||
final File outputFile = new File(targetDir, filename);
|
||||
final File outputFile = new File(targetDir, fileId.getFilename());
|
||||
|
||||
final OutputStream outputStream = new FileOutputStream(outputFile);
|
||||
outputStream.write(bytes);
|
||||
|
@ -18,13 +18,17 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.apache.commons.lang3.builder.CompareToBuilder;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||
|
||||
public class XiaomiActivityFileId {
|
||||
public class XiaomiActivityFileId implements Comparable<XiaomiActivityFileId> {
|
||||
private final Date timestamp;
|
||||
private final int timezone;
|
||||
private final int type;
|
||||
@ -133,6 +137,32 @@ public class XiaomiActivityFileId {
|
||||
"}";
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(final XiaomiActivityFileId o) {
|
||||
return new CompareToBuilder()
|
||||
.append(timestamp, o.timestamp)
|
||||
.append(timezone, o.timezone)
|
||||
.append(type, o.type)
|
||||
.append(subtype, o.subtype)
|
||||
.append(getDetailType().getFetchOrder(), o.getDetailType().getFetchOrder())
|
||||
.append(version, o.version)
|
||||
.build();
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
||||
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"xiaomi_%s_%02X_%02X_%02X_v%d.bin",
|
||||
sdf.format(getTimestamp()),
|
||||
getTypeCode(),
|
||||
getSubtypeCode(),
|
||||
getDetailTypeCode(),
|
||||
getVersion()
|
||||
);
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
UNKNOWN(-1),
|
||||
ACTIVITY(0),
|
||||
@ -166,6 +196,7 @@ public class XiaomiActivityFileId {
|
||||
SPORTS_OUTDOOR_RUNNING(Type.SPORTS, 0x01),
|
||||
SPORTS_FREESTYLE(Type.SPORTS, 0x08),
|
||||
SPORTS_ELLIPTICAL(Type.SPORTS, 0x0B),
|
||||
SPORTS_OUTDOOR_WALKING(Type.SPORTS, 0x16),
|
||||
SPORTS_OUTDOOR_CYCLING(Type.SPORTS, 0x17),
|
||||
;
|
||||
|
||||
@ -208,6 +239,21 @@ public class XiaomiActivityFileId {
|
||||
return code;
|
||||
}
|
||||
|
||||
public int getFetchOrder() {
|
||||
// Fetch summary first, so we have the summary data for workouts
|
||||
// before parsing the gps track
|
||||
switch (this) {
|
||||
case SUMMARY:
|
||||
return 0;
|
||||
case DETAILS:
|
||||
return 1;
|
||||
case GPS_TRACK:
|
||||
return 2;
|
||||
}
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
public static DetailType fromCode(final int code) {
|
||||
for (final DetailType detailType : values()) {
|
||||
if (detailType.getCode() == code) {
|
||||
|
@ -21,9 +21,20 @@ import androidx.annotation.Nullable;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import de.greenrobot.dao.query.QueryBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.User;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.DailyDetailsParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.DailySummaryParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.SleepDetailsParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutGpsParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser;
|
||||
|
||||
public abstract class XiaomiActivityParser {
|
||||
@ -31,6 +42,34 @@ public abstract class XiaomiActivityParser {
|
||||
|
||||
public abstract boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes);
|
||||
|
||||
protected BaseActivitySummary findOrCreateBaseActivitySummary(final DaoSession session,
|
||||
final Device device,
|
||||
final User user,
|
||||
final XiaomiActivityFileId fileId) {
|
||||
final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
|
||||
final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
|
||||
qb.where(BaseActivitySummaryDao.Properties.StartTime.eq(fileId.getTimestamp()));
|
||||
qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId()));
|
||||
qb.where(BaseActivitySummaryDao.Properties.UserId.eq(user.getId()));
|
||||
final List<BaseActivitySummary> summaries = qb.build().list();
|
||||
if (summaries.isEmpty()) {
|
||||
final BaseActivitySummary summary = new BaseActivitySummary();
|
||||
summary.setStartTime(fileId.getTimestamp());
|
||||
summary.setDevice(device);
|
||||
summary.setUser(user);
|
||||
|
||||
// These will be set later, once we parse the summary
|
||||
summary.setEndTime(fileId.getTimestamp());
|
||||
summary.setActivityKind(ActivityKind.TYPE_UNKNOWN);
|
||||
|
||||
return summary;
|
||||
}
|
||||
if (summaries.size() > 1) {
|
||||
LOG.warn("Found multiple summaries for {}", fileId);
|
||||
}
|
||||
return summaries.get(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static XiaomiActivityParser create(final XiaomiActivityFileId fileId) {
|
||||
switch (fileId.getType()) {
|
||||
@ -52,6 +91,9 @@ public abstract class XiaomiActivityParser {
|
||||
if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.DETAILS) {
|
||||
return new DailyDetailsParser();
|
||||
}
|
||||
if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.SUMMARY) {
|
||||
return new DailySummaryParser();
|
||||
}
|
||||
|
||||
break;
|
||||
case ACTIVITY_SLEEP:
|
||||
@ -71,6 +113,8 @@ public abstract class XiaomiActivityParser {
|
||||
switch (fileId.getDetailType()) {
|
||||
case SUMMARY:
|
||||
return new WorkoutSummaryParser();
|
||||
case GPS_TRACK:
|
||||
return new WorkoutGpsParser();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -69,6 +69,8 @@ public class DailyDetailsParser extends XiaomiActivityParser {
|
||||
final byte[] header = new byte[headerSize];
|
||||
buf.get(header);
|
||||
|
||||
LOG.debug("Daily Details Header: {}", GB.hexdump(header));
|
||||
|
||||
if ((buf.limit() - buf.position()) % sampleSize != 0) {
|
||||
LOG.warn("Remaining data in the buffer is not a multiple of {}", sampleSize);
|
||||
return false;
|
||||
@ -85,8 +87,11 @@ public class DailyDetailsParser extends XiaomiActivityParser {
|
||||
|
||||
sample.setSteps(buf.getShort());
|
||||
|
||||
final byte[] unknown1 = new byte[4];
|
||||
buf.get(unknown1); // TODO intensity and kind?
|
||||
final int calories = buf.get() & 0xff;
|
||||
final int unk2 = buf.get() & 0xff;
|
||||
final int distance = buf.getShort(); // not just walking, includes workouts like cycling
|
||||
|
||||
// TODO persist calories and distance, add UI
|
||||
|
||||
sample.setHeartRate(buf.get() & 0xff);
|
||||
|
||||
|
@ -0,0 +1,88 @@
|
||||
/* Copyright (C) 2023 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public class DailySummaryParser extends XiaomiActivityParser {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DailySummaryParser.class);
|
||||
|
||||
@Override
|
||||
public boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes) {
|
||||
final int version = fileId.getVersion();
|
||||
final int headerSize;
|
||||
switch (version) {
|
||||
case 5:
|
||||
headerSize = 4;
|
||||
break;
|
||||
default:
|
||||
LOG.warn("Unable to parse daily summary version {}", fileId.getVersion());
|
||||
return false;
|
||||
}
|
||||
|
||||
final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
final byte[] header = new byte[headerSize];
|
||||
buf.get(header);
|
||||
|
||||
LOG.debug("Header: {}", GB.hexdump(header));
|
||||
|
||||
final int steps = buf.getInt();
|
||||
final int unk1 = buf.get() & 0xff; // 0
|
||||
final int unk2 = buf.get() & 0xff; // 0
|
||||
final int unk3 = buf.get() & 0xff; // 0
|
||||
final int hrResting = buf.get() & 0xff;
|
||||
final int hrMax = buf.get() & 0xff;
|
||||
final int hrMaxTs = buf.getInt();
|
||||
final int hrMin = buf.get() & 0xff;
|
||||
final int hrMinTs = buf.getInt();
|
||||
final int hrAvg = buf.get() & 0xff;
|
||||
final int stressAvg = buf.get() & 0xff;
|
||||
final int stressMax = buf.get() & 0xff;
|
||||
final int stressMin = buf.get() & 0xff;
|
||||
final int unk4 = buf.get() & 0xff; // 0
|
||||
final int unk5 = buf.get() & 0xff; // 0
|
||||
final int unk6 = buf.get() & 0xff; // 0
|
||||
final int calories = buf.getShort();
|
||||
final int unk7 = buf.get() & 0xff; // 0
|
||||
final int unk8 = buf.get() & 0xff; // 0
|
||||
final int unk9 = buf.get() & 0xff; // 0
|
||||
final int spo2Max = buf.get() & 0xff;
|
||||
final int spo2MaxTs = buf.getInt();
|
||||
final int spo2Min = buf.get() & 0xff;
|
||||
final int spo2MinTs = buf.getInt();
|
||||
final int spo2Avg = buf.get() & 0xff;
|
||||
final int trainingLoadMaybe1 = buf.getShort();
|
||||
final int trainingLoadMaybe2 = buf.getShort();
|
||||
final int trainingLoadMaybe3 = buf.get() & 0xff;
|
||||
|
||||
// TODO vitality somewhere?
|
||||
// TODO persist everything
|
||||
|
||||
LOG.warn("Persisting daily summary is not implemented");
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
/* Copyright (C) 2023 José Rebelo
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl;
|
||||
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Date;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.User;
|
||||
import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter;
|
||||
import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public class WorkoutGpsParser extends XiaomiActivityParser {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(WorkoutGpsParser.class);
|
||||
|
||||
@Override
|
||||
public boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes) {
|
||||
final int version = fileId.getVersion();
|
||||
final int headerSize;
|
||||
final int sampleSize;
|
||||
switch (version) {
|
||||
case 1:
|
||||
case 2:
|
||||
headerSize = 1;
|
||||
sampleSize = 18;
|
||||
break;
|
||||
default:
|
||||
LOG.warn("Unable to parse workout gps version {}", fileId.getVersion());
|
||||
return false;
|
||||
}
|
||||
|
||||
final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
final byte[] header = new byte[headerSize];
|
||||
buf.get(header);
|
||||
|
||||
LOG.debug("Workout gps Header: {}", GB.hexdump(header));
|
||||
|
||||
if ((buf.limit() - buf.position()) % sampleSize != 0) {
|
||||
LOG.warn("Remaining data in the buffer is not a multiple of {}", sampleSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
final ActivityTrack activityTrack = new ActivityTrack();
|
||||
|
||||
while (buf.position() < buf.limit()) {
|
||||
final int ts = buf.getInt();
|
||||
final float longitude = buf.getFloat();
|
||||
final float latitude = buf.getFloat();
|
||||
final int unk1 = buf.getInt(); // 0
|
||||
final float speed = (buf.getShort() >> 2) / 10.0f;
|
||||
|
||||
final ActivityPoint ap = new ActivityPoint(new Date(ts * 1000L));
|
||||
ap.setLocation(new GPSCoordinate(longitude, latitude, 0));
|
||||
activityTrack.addTrackPoint(ap);
|
||||
|
||||
LOG.trace("ActivityPoint: ts={} lon={} lat={} unk1={} speed={}", ts, longitude, latitude, unk1, speed);
|
||||
}
|
||||
|
||||
try (DBHandler dbHandler = GBApplication.acquireDB()) {
|
||||
final DaoSession session = dbHandler.getDaoSession();
|
||||
final Device device = DBHelper.getDevice(support.getDevice(), session);
|
||||
final User user = DBHelper.getUser(session);
|
||||
|
||||
// Find the matching summary
|
||||
final BaseActivitySummary summary = findOrCreateBaseActivitySummary(session, device, user, fileId);
|
||||
|
||||
// Set the info on the activity track
|
||||
activityTrack.setUser(user);
|
||||
activityTrack.setDevice(device);
|
||||
activityTrack.setName(ActivityKind.asString(summary.getActivityKind(), support.getContext()));
|
||||
|
||||
// Save the raw bytes
|
||||
final String rawBytesPath = saveRawBytes(fileId, bytes);
|
||||
|
||||
// Save the gpx file
|
||||
final GPXExporter exporter = new GPXExporter();
|
||||
exporter.setCreator(GBApplication.app().getNameAndVersion());
|
||||
|
||||
final String gpxFileName = FileUtils.makeValidFileName("gadgetbridge-" + DateTimeUtils.formatIso8601(fileId.getTimestamp()) + ".gpx");
|
||||
final File gpxTargetFile = new File(FileUtils.getExternalFilesDir(), gpxFileName);
|
||||
|
||||
boolean exportGpxSuccess = true;
|
||||
try {
|
||||
exporter.performExport(activityTrack, gpxTargetFile);
|
||||
} catch (final ActivityTrackExporter.GPXTrackEmptyException ex) {
|
||||
exportGpxSuccess = false;
|
||||
GB.toast(support.getContext(), "This activity does not contain GPX tracks.", Toast.LENGTH_LONG, GB.ERROR, ex);
|
||||
}
|
||||
|
||||
if (exportGpxSuccess) {
|
||||
summary.setGpxTrack(gpxTargetFile.getAbsolutePath());
|
||||
}
|
||||
if (rawBytesPath != null) {
|
||||
summary.setRawDetailsPath(rawBytesPath);
|
||||
}
|
||||
session.getBaseActivitySummaryDao().insertOrReplace(summary);
|
||||
} catch (final Exception e) {
|
||||
GB.toast(support.getContext(), "Error saving workout gps", Toast.LENGTH_LONG, GB.ERROR, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private String saveRawBytes(final XiaomiActivityFileId fileId, final byte[] bytes) {
|
||||
try {
|
||||
final File targetFolder = new File(FileUtils.getExternalFilesDir(), "rawDetails");
|
||||
targetFolder.mkdirs();
|
||||
final File targetFile = new File(targetFolder, fileId.getFilename());
|
||||
FileOutputStream outputStream = new FileOutputStream(targetFile);
|
||||
outputStream.write(fileId.toBytes());
|
||||
outputStream.write(bytes);
|
||||
outputStream.close();
|
||||
return targetFile.getAbsolutePath();
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to save raw bytes", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -71,9 +71,14 @@ public class WorkoutSummaryParser extends XiaomiActivityParser implements Activi
|
||||
final DaoSession session = dbHandler.getDaoSession();
|
||||
final Device device = DBHelper.getDevice(support.getDevice(), session);
|
||||
final User user = DBHelper.getUser(session);
|
||||
summary.setDevice(device);
|
||||
summary.setUser(user);
|
||||
session.getBaseActivitySummaryDao().insertOrReplace(summary);
|
||||
|
||||
final BaseActivitySummary existingSummary = findOrCreateBaseActivitySummary(session, device, user, fileId);
|
||||
existingSummary.setEndTime(summary.getEndTime());
|
||||
existingSummary.setActivityKind(summary.getActivityKind());
|
||||
existingSummary.setRawSummaryData(summary.getRawSummaryData());
|
||||
existingSummary.setSummaryData(null); // remove json before saving to database
|
||||
|
||||
session.getBaseActivitySummaryDao().insertOrReplace(existingSummary);
|
||||
} catch (final Exception e) {
|
||||
GB.toast(support.getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e);
|
||||
return false;
|
||||
@ -117,7 +122,9 @@ public class WorkoutSummaryParser extends XiaomiActivityParser implements Activi
|
||||
final int startTime = buf.getInt();
|
||||
final int endTime = buf.getInt();
|
||||
|
||||
summary.setStartTime(new Date(startTime * 1000L));
|
||||
// We don't set the start time, since we need it to match the fileId for the WorkoutGpsParser
|
||||
// to find it. They also seem to match.
|
||||
//summary.setStartTime(new Date(startTime * 1000L));
|
||||
summary.setEndTime(new Date(endTime * 1000L));
|
||||
|
||||
final int duration = buf.getInt();
|
||||
|
@ -29,9 +29,11 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
@ -775,12 +777,14 @@ public class XiaomiHealthService extends AbstractXiaomiService {
|
||||
LOG.debug("Got {} activity file IDs", recordIds.length / 7);
|
||||
|
||||
final ByteBuffer buf = ByteBuffer.wrap(recordIds).order(ByteOrder.LITTLE_ENDIAN);
|
||||
final List<XiaomiActivityFileId> fileIds = new ArrayList<>();
|
||||
|
||||
while (buf.position() < buf.limit()) {
|
||||
final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(buf);
|
||||
LOG.debug("Got activity to fetch: {}", fileId);
|
||||
activityFetcher.fetch(fileId);
|
||||
fileIds.add(fileId);
|
||||
}
|
||||
activityFetcher.fetch(fileIds);
|
||||
|
||||
if (subtype == CMD_ACTIVITY_FETCH_TODAY) {
|
||||
LOG.debug("Fetch recorded data from the past");
|
||||
|
Loading…
Reference in New Issue
Block a user