mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-07-02 17:56:35 +02: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
|
@Override
|
||||||
public boolean supportsActivityTracks() {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,25 +245,19 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsHeartRateStats() {
|
public boolean supportsHeartRateStats() {
|
||||||
// TODO does it?
|
// TODO it does - see DailySummaryParser
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsPai() {
|
public boolean supportsPai() {
|
||||||
// TODO does it?
|
// TODO it does - vitality score
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsSleepRespiratoryRate() {
|
public boolean supportsSleepRespiratoryRate() {
|
||||||
// TODO does it?
|
// TODO it does
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean supportsAlarmDescription(final GBDevice device) {
|
|
||||||
// TODO does it?
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -73,6 +73,12 @@ public class MiBand8Coordinator extends XiaomiCoordinator {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsActivityTracks() {
|
||||||
|
// FIXME still has some issues
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsMultipleWeatherLocations() {
|
public boolean supportsMultipleWeatherLocations() {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -19,13 +19,9 @@ package nodomain.freeyourgadget.gadgetbridge.export;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
|
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
|
||||||
|
|
||||||
public interface ActivityTrackExporter {
|
public interface ActivityTrackExporter {
|
||||||
@NonNull
|
|
||||||
String getDefaultFileName(@NonNull ActivityTrack track);
|
|
||||||
|
|
||||||
void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException;
|
void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException;
|
||||||
|
|
||||||
class GPXTrackEmptyException extends Exception {
|
class GPXTrackEmptyException extends Exception {
|
||||||
|
|
|
@ -19,7 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.export;
|
||||||
|
|
||||||
import android.util.Xml;
|
import android.util.Xml;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.xmlpull.v1.XmlSerializer;
|
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.ActivityTrack;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
|
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
|
||||||
|
|
||||||
public class GPXExporter implements ActivityTrackExporter {
|
public class GPXExporter implements ActivityTrackExporter {
|
||||||
private static final String NS_GPX_URI = "http://www.topografix.com/GPX/1/1";
|
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 includeHeartRate = true;
|
||||||
private boolean includeHeartRateOfNearestSample = true;
|
private boolean includeHeartRateOfNearestSample = true;
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public String getDefaultFileName(@NonNull ActivityTrack track) {
|
|
||||||
return FileUtils.makeValidFileName(track.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException {
|
public void performExport(ActivityTrack track, File targetFile) throws IOException, GPXTrackEmptyException {
|
||||||
String encoding = StandardCharsets.UTF_8.name();
|
String encoding = StandardCharsets.UTF_8.name();
|
||||||
|
|
|
@ -38,9 +38,7 @@ public class ActivityPoint {
|
||||||
private Date time;
|
private Date time;
|
||||||
private GPSCoordinate location;
|
private GPSCoordinate location;
|
||||||
private int heartRate;
|
private int heartRate;
|
||||||
private long speed4;
|
private float speed = -1;
|
||||||
private long speed5;
|
|
||||||
private long speed6;
|
|
||||||
|
|
||||||
// e.g. to describe a pause during the activity
|
// e.g. to describe a pause during the activity
|
||||||
private @Nullable String description;
|
private @Nullable String description;
|
||||||
|
@ -85,27 +83,11 @@ public class ActivityPoint {
|
||||||
this.heartRate = heartRate;
|
this.heartRate = heartRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getSpeed4() {
|
public float getSpeed() {
|
||||||
return speed4;
|
return speed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSpeed4(long speed4) {
|
public void setSpeed(float speed) {
|
||||||
this.speed4 = speed4;
|
this.speed = speed;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,9 @@ import java.io.OutputStream;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.PriorityQueue;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
|
|
||||||
|
@ -47,7 +49,7 @@ public class XiaomiActivityFileFetcher {
|
||||||
|
|
||||||
private final XiaomiHealthService mHealthService;
|
private final XiaomiHealthService mHealthService;
|
||||||
|
|
||||||
private final Queue<XiaomiActivityFileId> mFetchQueue = new LinkedList<>();
|
private final Queue<XiaomiActivityFileId> mFetchQueue = new PriorityQueue<>();
|
||||||
private ByteArrayOutputStream mBuffer = new ByteArrayOutputStream();
|
private ByteArrayOutputStream mBuffer = new ByteArrayOutputStream();
|
||||||
private boolean isFetching = false;
|
private boolean isFetching = false;
|
||||||
|
|
||||||
|
@ -124,15 +126,15 @@ public class XiaomiActivityFileFetcher {
|
||||||
LOG.warn("Failed to parse {}", fileId);
|
LOG.warn("Failed to parse {}", fileId);
|
||||||
}
|
}
|
||||||
} catch (final Exception ex) {
|
} catch (final Exception ex) {
|
||||||
LOG.error("addChunk(): failed to parse activity: ", ex);
|
LOG.error("Exception while parsing " + fileId, ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerNextFetch();
|
triggerNextFetch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void fetch(final XiaomiActivityFileId fileId) {
|
public void fetch(final List<XiaomiActivityFileId> fileIds) {
|
||||||
mFetchQueue.add(fileId);
|
mFetchQueue.addAll(fileIds);
|
||||||
if (!isFetching) {
|
if (!isFetching) {
|
||||||
// Currently not fetching anything, fetch the next
|
// Currently not fetching anything, fetch the next
|
||||||
isFetching = true;
|
isFetching = true;
|
||||||
|
@ -163,23 +165,12 @@ public class XiaomiActivityFileFetcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void dumpBytesToExternalStorage(final XiaomiActivityFileId fileId, final byte[] bytes) {
|
protected void dumpBytesToExternalStorage(final XiaomiActivityFileId fileId, final byte[] bytes) {
|
||||||
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final File externalFilesDir = FileUtils.getExternalFilesDir();
|
final File externalFilesDir = FileUtils.getExternalFilesDir();
|
||||||
final File targetDir = new File(externalFilesDir, "rawFetchOperations");
|
final File targetDir = new File(externalFilesDir, "rawFetchOperations");
|
||||||
targetDir.mkdirs();
|
targetDir.mkdirs();
|
||||||
|
|
||||||
final String filename = String.format(
|
final File outputFile = new File(targetDir, fileId.getFilename());
|
||||||
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 OutputStream outputStream = new FileOutputStream(outputFile);
|
final OutputStream outputStream = new FileOutputStream(outputFile);
|
||||||
outputStream.write(bytes);
|
outputStream.write(bytes);
|
||||||
|
|
|
@ -18,13 +18,17 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.builder.CompareToBuilder;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||||
|
|
||||||
public class XiaomiActivityFileId {
|
public class XiaomiActivityFileId implements Comparable<XiaomiActivityFileId> {
|
||||||
private final Date timestamp;
|
private final Date timestamp;
|
||||||
private final int timezone;
|
private final int timezone;
|
||||||
private final int type;
|
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 {
|
public enum Type {
|
||||||
UNKNOWN(-1),
|
UNKNOWN(-1),
|
||||||
ACTIVITY(0),
|
ACTIVITY(0),
|
||||||
|
@ -166,6 +196,7 @@ public class XiaomiActivityFileId {
|
||||||
SPORTS_OUTDOOR_RUNNING(Type.SPORTS, 0x01),
|
SPORTS_OUTDOOR_RUNNING(Type.SPORTS, 0x01),
|
||||||
SPORTS_FREESTYLE(Type.SPORTS, 0x08),
|
SPORTS_FREESTYLE(Type.SPORTS, 0x08),
|
||||||
SPORTS_ELLIPTICAL(Type.SPORTS, 0x0B),
|
SPORTS_ELLIPTICAL(Type.SPORTS, 0x0B),
|
||||||
|
SPORTS_OUTDOOR_WALKING(Type.SPORTS, 0x16),
|
||||||
SPORTS_OUTDOOR_CYCLING(Type.SPORTS, 0x17),
|
SPORTS_OUTDOOR_CYCLING(Type.SPORTS, 0x17),
|
||||||
;
|
;
|
||||||
|
|
||||||
|
@ -208,6 +239,21 @@ public class XiaomiActivityFileId {
|
||||||
return code;
|
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) {
|
public static DetailType fromCode(final int code) {
|
||||||
for (final DetailType detailType : values()) {
|
for (final DetailType detailType : values()) {
|
||||||
if (detailType.getCode() == code) {
|
if (detailType.getCode() == code) {
|
||||||
|
|
|
@ -21,9 +21,20 @@ import androidx.annotation.Nullable;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.XiaomiSupport;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.DailyDetailsParser;
|
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.SleepDetailsParser;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutGpsParser;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser;
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser;
|
||||||
|
|
||||||
public abstract class XiaomiActivityParser {
|
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);
|
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
|
@Nullable
|
||||||
public static XiaomiActivityParser create(final XiaomiActivityFileId fileId) {
|
public static XiaomiActivityParser create(final XiaomiActivityFileId fileId) {
|
||||||
switch (fileId.getType()) {
|
switch (fileId.getType()) {
|
||||||
|
@ -52,6 +91,9 @@ public abstract class XiaomiActivityParser {
|
||||||
if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.DETAILS) {
|
if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.DETAILS) {
|
||||||
return new DailyDetailsParser();
|
return new DailyDetailsParser();
|
||||||
}
|
}
|
||||||
|
if (fileId.getDetailType() == XiaomiActivityFileId.DetailType.SUMMARY) {
|
||||||
|
return new DailySummaryParser();
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case ACTIVITY_SLEEP:
|
case ACTIVITY_SLEEP:
|
||||||
|
@ -71,6 +113,8 @@ public abstract class XiaomiActivityParser {
|
||||||
switch (fileId.getDetailType()) {
|
switch (fileId.getDetailType()) {
|
||||||
case SUMMARY:
|
case SUMMARY:
|
||||||
return new WorkoutSummaryParser();
|
return new WorkoutSummaryParser();
|
||||||
|
case GPS_TRACK:
|
||||||
|
return new WorkoutGpsParser();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -69,6 +69,8 @@ public class DailyDetailsParser extends XiaomiActivityParser {
|
||||||
final byte[] header = new byte[headerSize];
|
final byte[] header = new byte[headerSize];
|
||||||
buf.get(header);
|
buf.get(header);
|
||||||
|
|
||||||
|
LOG.debug("Daily Details Header: {}", GB.hexdump(header));
|
||||||
|
|
||||||
if ((buf.limit() - buf.position()) % sampleSize != 0) {
|
if ((buf.limit() - buf.position()) % sampleSize != 0) {
|
||||||
LOG.warn("Remaining data in the buffer is not a multiple of {}", sampleSize);
|
LOG.warn("Remaining data in the buffer is not a multiple of {}", sampleSize);
|
||||||
return false;
|
return false;
|
||||||
|
@ -85,8 +87,11 @@ public class DailyDetailsParser extends XiaomiActivityParser {
|
||||||
|
|
||||||
sample.setSteps(buf.getShort());
|
sample.setSteps(buf.getShort());
|
||||||
|
|
||||||
final byte[] unknown1 = new byte[4];
|
final int calories = buf.get() & 0xff;
|
||||||
buf.get(unknown1); // TODO intensity and kind?
|
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);
|
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 DaoSession session = dbHandler.getDaoSession();
|
||||||
final Device device = DBHelper.getDevice(support.getDevice(), session);
|
final Device device = DBHelper.getDevice(support.getDevice(), session);
|
||||||
final User user = DBHelper.getUser(session);
|
final User user = DBHelper.getUser(session);
|
||||||
summary.setDevice(device);
|
|
||||||
summary.setUser(user);
|
final BaseActivitySummary existingSummary = findOrCreateBaseActivitySummary(session, device, user, fileId);
|
||||||
session.getBaseActivitySummaryDao().insertOrReplace(summary);
|
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) {
|
} catch (final Exception e) {
|
||||||
GB.toast(support.getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e);
|
GB.toast(support.getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e);
|
||||||
return false;
|
return false;
|
||||||
|
@ -117,7 +122,9 @@ public class WorkoutSummaryParser extends XiaomiActivityParser implements Activi
|
||||||
final int startTime = buf.getInt();
|
final int startTime = buf.getInt();
|
||||||
final int endTime = 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));
|
summary.setEndTime(new Date(endTime * 1000L));
|
||||||
|
|
||||||
final int duration = buf.getInt();
|
final int duration = buf.getInt();
|
||||||
|
|
|
@ -29,9 +29,11 @@ import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.GregorianCalendar;
|
import java.util.GregorianCalendar;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||||
|
@ -775,12 +777,14 @@ public class XiaomiHealthService extends AbstractXiaomiService {
|
||||||
LOG.debug("Got {} activity file IDs", recordIds.length / 7);
|
LOG.debug("Got {} activity file IDs", recordIds.length / 7);
|
||||||
|
|
||||||
final ByteBuffer buf = ByteBuffer.wrap(recordIds).order(ByteOrder.LITTLE_ENDIAN);
|
final ByteBuffer buf = ByteBuffer.wrap(recordIds).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
final List<XiaomiActivityFileId> fileIds = new ArrayList<>();
|
||||||
|
|
||||||
while (buf.position() < buf.limit()) {
|
while (buf.position() < buf.limit()) {
|
||||||
final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(buf);
|
final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(buf);
|
||||||
LOG.debug("Got activity to fetch: {}", fileId);
|
LOG.debug("Got activity to fetch: {}", fileId);
|
||||||
activityFetcher.fetch(fileId);
|
fileIds.add(fileId);
|
||||||
}
|
}
|
||||||
|
activityFetcher.fetch(fileIds);
|
||||||
|
|
||||||
if (subtype == CMD_ACTIVITY_FETCH_TODAY) {
|
if (subtype == CMD_ACTIVITY_FETCH_TODAY) {
|
||||||
LOG.debug("Fetch recorded data from the past");
|
LOG.debug("Fetch recorded data from the past");
|
||||||
|
|
Loading…
Reference in New Issue
Block a user