mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-08-24 16:21:16 +02:00
Mi Band 8: Activity fetching basics
This commit is contained in:
parent
4662da3b82
commit
544909a970
@ -80,6 +80,7 @@ public class XiaomiEncryptedSupport extends XiaomiSupport {
|
|||||||
this.characteristicCommandWrite = new XiaomiCharacteristic(this, btCharacteristicCommandWrite, authService);
|
this.characteristicCommandWrite = new XiaomiCharacteristic(this, btCharacteristicCommandWrite, authService);
|
||||||
this.characteristicCommandRead.setEncrypted(true);
|
this.characteristicCommandRead.setEncrypted(true);
|
||||||
this.characteristicActivityData = new XiaomiCharacteristic(this, btCharacteristicActivityData, authService);
|
this.characteristicActivityData = new XiaomiCharacteristic(this, btCharacteristicActivityData, authService);
|
||||||
|
this.characteristicActivityData.setHandler(healthService.getActivityFetcher()::addChunk);
|
||||||
this.characteristicCommandRead.setEncrypted(true);
|
this.characteristicCommandRead.setEncrypted(true);
|
||||||
this.characteristicDataUpload = new XiaomiCharacteristic(this, btCharacteristicDataUpload, authService);
|
this.characteristicDataUpload = new XiaomiCharacteristic(this, btCharacteristicDataUpload, authService);
|
||||||
this.characteristicCommandRead.setEncrypted(true);
|
this.characteristicCommandRead.setEncrypted(true);
|
||||||
|
@ -0,0 +1,145 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services.XiaomiHealthService;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||||
|
|
||||||
|
public class XiaomiActivityFileFetcher {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(XiaomiActivityFileFetcher.class);
|
||||||
|
|
||||||
|
private final XiaomiHealthService mHealthService;
|
||||||
|
|
||||||
|
private final Queue<List<XiaomiActivityFileId>> mFetchQueue = new LinkedList<>();
|
||||||
|
private ByteArrayOutputStream mBuffer = null;
|
||||||
|
private Set<XiaomiActivityFileId> pendingFiles = new HashSet<>();
|
||||||
|
private boolean isFetching = false;
|
||||||
|
|
||||||
|
public XiaomiActivityFileFetcher(final XiaomiHealthService healthService) {
|
||||||
|
this.mHealthService = healthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addChunk(final byte[] chunk) {
|
||||||
|
final int total = BLETypeConversions.toUint16(chunk, 0);
|
||||||
|
final int num = BLETypeConversions.toUint16(chunk, 2);
|
||||||
|
|
||||||
|
LOG.debug("Got activity chunk {}/{}", num, total);
|
||||||
|
|
||||||
|
if (num == 1) {
|
||||||
|
if (mBuffer == null) {
|
||||||
|
mBuffer = new ByteArrayOutputStream();
|
||||||
|
}
|
||||||
|
mBuffer.reset();
|
||||||
|
mBuffer.write(chunk, 4, chunk.length - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num == total) {
|
||||||
|
final byte[] data = mBuffer.toByteArray();
|
||||||
|
mBuffer.reset();
|
||||||
|
mBuffer = null;
|
||||||
|
|
||||||
|
if (data.length < 13) {
|
||||||
|
LOG.warn("Activity data length of {} is too short", data.length);
|
||||||
|
// FIXME this may mess up the order.. maybe we should just abort
|
||||||
|
triggerNextFetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validChecksum(data)) {
|
||||||
|
LOG.warn("Invalid activity data checksum");
|
||||||
|
// FIXME this may mess up the order.. maybe we should just abort
|
||||||
|
triggerNextFetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data[7] != 0) {
|
||||||
|
LOG.warn(
|
||||||
|
"Unexpected activity payload byte {} at position 7 - parsing might fail",
|
||||||
|
String.format("0x%02X", data[7])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] fileIdBytes = Arrays.copyOfRange(data, 0, 7);
|
||||||
|
final byte[] activityData = Arrays.copyOfRange(data, 8, data.length - 4);
|
||||||
|
final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(fileIdBytes);
|
||||||
|
|
||||||
|
final XiaomiActivityParser activityParser = XiaomiActivityParser.create(fileId);
|
||||||
|
if (activityParser == null) {
|
||||||
|
LOG.warn("Failed to find activity parser for {}", fileId);
|
||||||
|
triggerNextFetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activityParser.parse(fileId, activityData)) {
|
||||||
|
LOG.debug("Acking recorded data {}", fileId);
|
||||||
|
//mHealthService.ackRecordedData(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME only after receiving everything triggerNextFetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void fetch(final List<XiaomiActivityFileId> fileIds) {
|
||||||
|
mFetchQueue.add(fileIds);
|
||||||
|
if (!isFetching) {
|
||||||
|
// Currently not fetching anything, fetch the next
|
||||||
|
final XiaomiSupport support = mHealthService.getSupport();
|
||||||
|
final Context context = support.getContext();
|
||||||
|
GB.updateTransferNotification(context.getString(R.string.busy_task_fetch_activity_data),"", true, 0, context);
|
||||||
|
support.getDevice().setBusyTask(context.getString(R.string.busy_task_fetch_activity_data));
|
||||||
|
triggerNextFetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void triggerNextFetch() {
|
||||||
|
final List<XiaomiActivityFileId> fileIds = mFetchQueue.poll();
|
||||||
|
|
||||||
|
if (fileIds == null || fileIds.isEmpty()) {
|
||||||
|
mHealthService.getSupport().getDevice().unsetBusyTask();
|
||||||
|
GB.updateTransferNotification(null, "", false, 100, mHealthService.getSupport().getContext());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mHealthService.requestRecordedData(fileIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validChecksum(final byte[] arr) {
|
||||||
|
final int arrCrc32 = CheckSums.getCRC32(arr, 0, arr.length - 4);
|
||||||
|
final int expectedCrc32 = BLETypeConversions.toUint32(arr, arr.length - 4);
|
||||||
|
|
||||||
|
return arrCrc32 == expectedCrc32;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,125 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||||
|
|
||||||
|
public class XiaomiActivityFileId {
|
||||||
|
public static final int TYPE_ACTIVITY = 0;
|
||||||
|
public static final int TYPE_SPORTS = 1;
|
||||||
|
|
||||||
|
public static final int TYPE_DETAILS = 0;
|
||||||
|
public static final int TYPE_SUMMARY = 1;
|
||||||
|
|
||||||
|
private final Date timestamp;
|
||||||
|
private final int timezone;
|
||||||
|
private final int type;
|
||||||
|
private final int subtype;
|
||||||
|
private final int detailType;
|
||||||
|
private final int version;
|
||||||
|
|
||||||
|
public XiaomiActivityFileId(final Date timestamp,
|
||||||
|
final int timezone,
|
||||||
|
final int type,
|
||||||
|
final int subtype,
|
||||||
|
final int detailType,
|
||||||
|
final int version) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.timezone = timezone;
|
||||||
|
this.type = type;
|
||||||
|
this.subtype = subtype;
|
||||||
|
this.detailType = detailType;
|
||||||
|
this.version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSubtype() {
|
||||||
|
return subtype;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getDetailType() {
|
||||||
|
return detailType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] toBytes() {
|
||||||
|
final ByteBuffer buf = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
|
||||||
|
buf.putInt((int) (timestamp.getTime() / 1000));
|
||||||
|
buf.put((byte) timezone);
|
||||||
|
buf.put((byte) version);
|
||||||
|
buf.put((byte) (type << 7 | subtype << 2 | detailType));
|
||||||
|
|
||||||
|
return buf.array();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static XiaomiActivityFileId from(final byte[] bytes) {
|
||||||
|
assert bytes.length == 7;
|
||||||
|
|
||||||
|
return from(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static XiaomiActivityFileId from(final ByteBuffer buf) {
|
||||||
|
final int ts = buf.getInt();
|
||||||
|
final int tz = buf.get(); // 15 min blocks
|
||||||
|
final int version = buf.get();
|
||||||
|
final int flags = buf.get();
|
||||||
|
// bit 0 is type - 0 activity, 1 sports
|
||||||
|
final int type = (flags >> 7) & 1;
|
||||||
|
// bit 1 to 6 bits are subtype
|
||||||
|
// for activity: activity, sleep, measurements, etc
|
||||||
|
// for workout: workout type (8 freestyle)
|
||||||
|
final int subtype = (flags & 127) >> 2;
|
||||||
|
// bit 6 and 7 - 0 details, 1 summary
|
||||||
|
final int detailType = flags & 3;
|
||||||
|
|
||||||
|
return new XiaomiActivityFileId(new Date(ts * 1000L), tz, version, type, subtype, detailType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return getClass().getSimpleName() + "{" +
|
||||||
|
"timestamp=" + DateTimeUtils.formatIso8601(timestamp) +
|
||||||
|
", timezone=" + timezone +
|
||||||
|
", type=" + type +
|
||||||
|
", subtype=" + subtype +
|
||||||
|
", detailType=" + detailType +
|
||||||
|
", version=" + version +
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,50 @@
|
|||||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity;
|
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity;
|
||||||
|
|
||||||
public class XiaomiActivityParser {
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||||
|
|
||||||
|
public abstract class XiaomiActivityParser {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(XiaomiActivityParser.class);
|
||||||
|
|
||||||
|
private final XiaomiSupport mSupport;
|
||||||
|
|
||||||
|
public XiaomiActivityParser(final XiaomiSupport support) {
|
||||||
|
this.mSupport = support;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract boolean parse(final XiaomiActivityFileId fileId, final byte[] bytes);
|
||||||
|
|
||||||
|
public XiaomiSupport getSupport() {
|
||||||
|
return mSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static XiaomiActivityParser create(final XiaomiActivityFileId fileId) {
|
||||||
|
switch (fileId.getType()) {
|
||||||
|
case XiaomiActivityFileId.TYPE_ACTIVITY:
|
||||||
|
return createForActivity(fileId);
|
||||||
|
case XiaomiActivityFileId.TYPE_SPORTS:
|
||||||
|
return createForSports(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG.warn("Unknown file type for {}", fileId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static XiaomiActivityParser createForActivity(final XiaomiActivityFileId fileId) {
|
||||||
|
assert fileId.getType() == XiaomiActivityFileId.TYPE_ACTIVITY;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static XiaomiActivityParser createForSports(final XiaomiActivityFileId fileId) {
|
||||||
|
assert fileId.getType() == XiaomiActivityFileId.TYPE_SPORTS;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ public abstract class AbstractXiaomiService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected XiaomiSupport getSupport() {
|
public XiaomiSupport getSupport() {
|
||||||
return mSupport;
|
return mSupport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,14 +20,18 @@ import android.content.Intent;
|
|||||||
|
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||||
|
|
||||||
|
import com.google.protobuf.ByteString;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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;
|
||||||
@ -49,6 +53,8 @@ import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileFetcher;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||||
|
|
||||||
@ -58,8 +64,10 @@ public class XiaomiHealthService extends AbstractXiaomiService {
|
|||||||
public static final int COMMAND_TYPE = 8;
|
public static final int COMMAND_TYPE = 8;
|
||||||
|
|
||||||
private static final int CMD_SET_USER_INFO = 0;
|
private static final int CMD_SET_USER_INFO = 0;
|
||||||
private static final int CMD_ACTIVITY_FETCH_1 = 1;
|
private static final int CMD_ACTIVITY_FETCH_TODAY = 1;
|
||||||
private static final int CMD_ACTIVITY_FETCH_2 = 2;
|
private static final int CMD_ACTIVITY_FETCH_PAST = 2;
|
||||||
|
private static final int CMD_ACTIVITY_FETCH_REQUEST = 3;
|
||||||
|
private static final int CMD_ACTIVITY_FETCH_ACK = 5;
|
||||||
private static final int CMD_CONFIG_SPO2_GET = 8;
|
private static final int CMD_CONFIG_SPO2_GET = 8;
|
||||||
private static final int CMD_CONFIG_SPO2_SET = 9;
|
private static final int CMD_CONFIG_SPO2_SET = 9;
|
||||||
private static final int CMD_CONFIG_HEART_RATE_GET = 10;
|
private static final int CMD_CONFIG_HEART_RATE_GET = 10;
|
||||||
@ -79,6 +87,8 @@ public class XiaomiHealthService extends AbstractXiaomiService {
|
|||||||
private boolean realtimeOneShot = false;
|
private boolean realtimeOneShot = false;
|
||||||
private int previousSteps = -1;
|
private int previousSteps = -1;
|
||||||
|
|
||||||
|
private final XiaomiActivityFileFetcher activityFetcher = new XiaomiActivityFileFetcher(this);
|
||||||
|
|
||||||
public XiaomiHealthService(final XiaomiSupport support) {
|
public XiaomiHealthService(final XiaomiSupport support) {
|
||||||
super(support);
|
super(support);
|
||||||
}
|
}
|
||||||
@ -89,9 +99,9 @@ public class XiaomiHealthService extends AbstractXiaomiService {
|
|||||||
case CMD_SET_USER_INFO:
|
case CMD_SET_USER_INFO:
|
||||||
LOG.debug("Got user info set ack, status={}", cmd.getStatus());
|
LOG.debug("Got user info set ack, status={}", cmd.getStatus());
|
||||||
return;
|
return;
|
||||||
case CMD_ACTIVITY_FETCH_1:
|
case CMD_ACTIVITY_FETCH_TODAY:
|
||||||
case CMD_ACTIVITY_FETCH_2:
|
case CMD_ACTIVITY_FETCH_PAST:
|
||||||
handleActivityFetchResponse(cmd.getHealth().getActivityRecordIds().toByteArray());
|
handleActivityFetchResponse(cmd.getSubtype(), cmd.getHealth().getActivityRequestFileIds().toByteArray());
|
||||||
return;
|
return;
|
||||||
case CMD_CONFIG_SPO2_GET:
|
case CMD_CONFIG_SPO2_GET:
|
||||||
handleSpo2Config(cmd.getHealth().getSpo2());
|
handleSpo2Config(cmd.getHealth().getSpo2());
|
||||||
@ -395,62 +405,97 @@ public class XiaomiHealthService extends AbstractXiaomiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public XiaomiActivityFileFetcher getActivityFetcher() {
|
||||||
|
return activityFetcher;
|
||||||
|
}
|
||||||
|
|
||||||
public void onFetchRecordedData(final int dataTypes) {
|
public void onFetchRecordedData(final int dataTypes) {
|
||||||
LOG.debug("Fetch recorded data: {}", dataTypes);
|
LOG.debug("Fetch recorded data: {}", dataTypes);
|
||||||
final TransactionBuilder builder = getSupport().createTransactionBuilder("fetch recorded data step 1");
|
|
||||||
|
|
||||||
|
fetchRecordedDataToday();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fetchRecordedDataToday() {
|
||||||
getSupport().sendCommand(
|
getSupport().sendCommand(
|
||||||
builder,
|
"fetch recorded data today",
|
||||||
XiaomiProto.Command.newBuilder()
|
XiaomiProto.Command.newBuilder()
|
||||||
.setType(COMMAND_TYPE)
|
.setType(COMMAND_TYPE)
|
||||||
.setSubtype(CMD_ACTIVITY_FETCH_1)
|
.setSubtype(CMD_ACTIVITY_FETCH_TODAY)
|
||||||
.setHealth(XiaomiProto.Health.newBuilder().setActivitySyncRequest1(
|
.setHealth(XiaomiProto.Health.newBuilder().setActivitySyncRequestToday(
|
||||||
// TODO official app sends 0, but doesn't work every time?
|
// TODO official app sends 0, but sometimes 1?
|
||||||
XiaomiProto.ActivitySyncRequest1.newBuilder().setUnknown1(1)
|
XiaomiProto.ActivitySyncRequestToday.newBuilder().setUnknown1(0)
|
||||||
))
|
))
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO we need to wait for the reply from the previous before sending this one?
|
|
||||||
//getSupport().sendCommand(
|
|
||||||
// builder,
|
|
||||||
// XiaomiProto.Command.newBuilder()
|
|
||||||
// .setType(COMMAND_TYPE)
|
|
||||||
// .setSubtype(CMD_ACTIVITY_FETCH_2)
|
|
||||||
// .build()
|
|
||||||
//);
|
|
||||||
|
|
||||||
builder.queue(getSupport().getQueue());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void handleActivityFetchResponse(final byte[] recordIds) {
|
private void fetchRecordedDataPast() {
|
||||||
|
getSupport().sendCommand(
|
||||||
|
"fetch recorded data past",
|
||||||
|
XiaomiProto.Command.newBuilder()
|
||||||
|
.setType(COMMAND_TYPE)
|
||||||
|
.setSubtype(CMD_ACTIVITY_FETCH_PAST)
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requestRecordedData(final List<XiaomiActivityFileId> fileIds) {
|
||||||
|
final ByteBuffer buf = ByteBuffer.allocate(7 * fileIds.size()).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
for (final XiaomiActivityFileId fileId : fileIds) {
|
||||||
|
buf.put(fileId.toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
getSupport().sendCommand(
|
||||||
|
"request recorded data",
|
||||||
|
XiaomiProto.Command.newBuilder()
|
||||||
|
.setType(COMMAND_TYPE)
|
||||||
|
.setSubtype(CMD_ACTIVITY_FETCH_REQUEST)
|
||||||
|
.setHealth(XiaomiProto.Health.newBuilder().setActivityRequestFileIds(
|
||||||
|
ByteString.copyFrom(buf.array())
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ackRecordedData(final XiaomiActivityFileId fileId) {
|
||||||
|
getSupport().sendCommand(
|
||||||
|
"ack recorded data",
|
||||||
|
XiaomiProto.Command.newBuilder()
|
||||||
|
.setType(COMMAND_TYPE)
|
||||||
|
.setSubtype(CMD_ACTIVITY_FETCH_ACK)
|
||||||
|
.setHealth(XiaomiProto.Health.newBuilder().setActivitySyncAckFileIds(
|
||||||
|
ByteString.copyFrom(fileId.toBytes())
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void handleActivityFetchResponse(final int subtype, final byte[] recordIds) {
|
||||||
if ((recordIds.length % 7) != 0) {
|
if ((recordIds.length % 7) != 0) {
|
||||||
LOG.warn("recordIds {} length = {}, not a multiple of 7, can't parse", GB.hexdump(recordIds), recordIds.length);
|
LOG.warn("recordIds {} length = {}, not a multiple of 7, can't parse", GB.hexdump(recordIds), recordIds.length);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.debug("Got {} record IDs", recordIds.length / 7);
|
LOG.debug("Got {} record 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 int ts = buf.getInt();
|
final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(buf);
|
||||||
final int tz = buf.get(); // 15 min blocks
|
LOG.debug("Got activity to fetch: {}", fileId);
|
||||||
final int version = buf.get();
|
fileIds.add(fileId);
|
||||||
final int flags = buf.get();
|
}
|
||||||
// bit 0 is type - 0 activity, 1 sports
|
|
||||||
final int type = (flags >> 7) & 1;
|
if (!fileIds.isEmpty()) {
|
||||||
// bit 1 to 6 bits are subtype
|
LOG.debug("Fetching {} files", fileIds.size());
|
||||||
// for activity: activity, sleep, measurements, etc
|
activityFetcher.fetch(fileIds);
|
||||||
// for workout: workout type (8 freestyle)
|
}
|
||||||
final int subtype = (flags & 127) >> 2;
|
|
||||||
// bit 6 and 7 - 0/1 - summary vs details
|
if (subtype == CMD_ACTIVITY_FETCH_TODAY) {
|
||||||
final int detailType = flags & 3;
|
LOG.debug("Fetch recorded data from the past");
|
||||||
LOG.debug(
|
// FIXME fix scheduling fetchRecordedDataPast();
|
||||||
"Activity Record: ts = {}, tz = {}, flags = {}, type = {}, subtype = {}, detailType = {}, version = {}",
|
|
||||||
ts, tz, String.format("0x%02X", flags), type, subtype, detailType, version
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -282,8 +282,11 @@ message Charger {
|
|||||||
message Health {
|
message Health {
|
||||||
optional UserInfo userInfo = 1;
|
optional UserInfo userInfo = 1;
|
||||||
|
|
||||||
optional bytes activityRecordIds = 2;
|
// 8, 2 get today | 8, 3 get past
|
||||||
optional ActivitySyncRequest1 activitySyncRequest1 = 5;
|
optional bytes activityRequestFileIds = 2;
|
||||||
|
//
|
||||||
|
optional bytes activitySyncAckFileIds = 3;
|
||||||
|
optional ActivitySyncRequestToday activitySyncRequestToday = 5;
|
||||||
|
|
||||||
optional SpO2 spo2 = 7;
|
optional SpO2 spo2 = 7;
|
||||||
optional HeartRate heartRate = 8;
|
optional HeartRate heartRate = 8;
|
||||||
@ -321,8 +324,8 @@ message UserInfo {
|
|||||||
optional uint32 goalMoving = 11; // minutes
|
optional uint32 goalMoving = 11; // minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
message ActivitySyncRequest1 {
|
message ActivitySyncRequestToday {
|
||||||
optional uint32 unknown1 = 1; // 0/1
|
optional uint32 unknown1 = 1; // 0 most of the time, sometimes 1
|
||||||
}
|
}
|
||||||
|
|
||||||
message SpO2 {
|
message SpO2 {
|
||||||
|
@ -0,0 +1,55 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||||
|
|
||||||
|
public class XiaomiActivityFileIdTest {
|
||||||
|
@Test
|
||||||
|
public void testEncode() {
|
||||||
|
final byte[] bytes = GB.hexStringToByteArray("21F328650403A0");
|
||||||
|
final XiaomiActivityFileId xiaomiActivityFileId = new XiaomiActivityFileId(
|
||||||
|
new Date(1697182497000L),
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
8,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
assertArrayEquals(bytes, xiaomiActivityFileId.toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDecode() {
|
||||||
|
final byte[] bytes = GB.hexStringToByteArray("21F328650403A0");
|
||||||
|
final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(bytes);
|
||||||
|
|
||||||
|
assertEquals(1697182497000L, fileId.getTimestamp().getTime());
|
||||||
|
assertEquals(4, fileId.getTimezone());
|
||||||
|
assertEquals(3, fileId.getVersion());
|
||||||
|
assertEquals(1, fileId.getType());
|
||||||
|
assertEquals(8, fileId.getSubtype());
|
||||||
|
assertEquals(0, fileId.getDetailType());
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user