1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-19 19:40:22 +02:00
Gadgetbridge/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ActivityDetailsParser.java
2023-05-14 16:22:32 +01:00

357 lines
12 KiB
Java

/* Copyright (C) 2022 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.huami;
import androidx.annotation.Nullable;
import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
public class Huami2021ActivityDetailsParser extends AbstractHuamiActivityDetailsParser {
private static final Logger LOG = LoggerFactory.getLogger(Huami2021ActivityDetailsParser.class);
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US);
static {
SDF.setTimeZone(TimeZone.getTimeZone("UTC"));
}
private Date timestamp;
private long offset = 0;
private long longitude;
private long latitude;
private double altitude;
private final ActivityTrack activityTrack;
private ActivityPoint lastActivityPoint;
public Huami2021ActivityDetailsParser(final BaseActivitySummary summary) {
this.timestamp = summary.getStartTime();
this.longitude = summary.getBaseLongitude();
this.latitude = summary.getBaseLatitude();
this.altitude = summary.getBaseAltitude();
this.activityTrack = new ActivityTrack();
this.activityTrack.setUser(summary.getUser());
this.activityTrack.setDevice(summary.getDevice());
this.activityTrack.setName(createActivityName(summary));
}
@Override
public ActivityTrack parse(final byte[] bytes) throws GBException {
final ByteBuffer buf = ByteBuffer.wrap(bytes)
.order(ByteOrder.LITTLE_ENDIAN);
// Keep track of unknown type codes so we can print them without spamming the logs
final Map<Byte, Integer> unknownTypeCodes = new HashMap<>();
while (buf.position() < buf.limit()) {
final byte typeCode = buf.get();
final byte length = buf.get();
final int initialPosition = buf.position();
final Type type = Type.fromCode(typeCode);
if (type == null) {
if (!unknownTypeCodes.containsKey(typeCode)) {
unknownTypeCodes.put(typeCode, 0);
}
unknownTypeCodes.put(typeCode, unknownTypeCodes.get(typeCode) + 1);
//LOG.warn("Unknown type code {} of length {}", String.format("0x%X", typeCode), length);
// Consume the reported length
buf.get(new byte[length]);
continue;
} else if (length != type.getExpectedLength()) {
LOG.warn("Unexpected length {} for type {}", length, type);
// Consume the reported length
buf.get(new byte[length]);
continue;
}
// Consume
switch (type) {
case TIMESTAMP:
consumeTimestamp(buf);
break;
case GPS_COORDS:
consumeGpsCoords(buf);
break;
case GPS_DELTA:
consumeGpsDelta(buf);
break;
case STATUS:
consumeStatus(buf);
break;
case SPEED:
consumeSpeed(buf);
break;
case ALTITUDE:
consumeAltitude(buf);
break;
case HEARTRATE:
consumeHeartRate(buf);
break;
case STRENGTH_SET:
// TODO parse strength sets: weight, count, type
default:
LOG.warn("No consumer for for type {}", type);
// Consume the reported length
buf.get(new byte[length]);
continue;
}
final int expectedPosition = initialPosition + length;
if (buf.position() != expectedPosition) {
// Should never happen unless there's a bug in one of the consumers
throw new IllegalStateException("Unexpected position " + buf.position() + ", expected " + expectedPosition + ", after consuming " + type);
}
}
if (!unknownTypeCodes.isEmpty()) {
for (final Map.Entry<Byte, Integer> e : unknownTypeCodes.entrySet()) {
LOG.warn("Unknown type code {} seen {} times", String.format("0x%X", e.getKey()), e.getValue());
}
}
return this.activityTrack;
}
private void consumeTimestamp(final ByteBuffer buf) {
buf.getInt(); // ?
this.timestamp = new Date(buf.getLong());
this.offset = 0;
//trace("Consumed timestamp");
}
private void consumeTimestampOffset(final ByteBuffer buf) {
this.offset = buf.getShort();
}
private void consumeGpsCoords(final ByteBuffer buf) {
buf.get(new byte[6]); // ?
this.longitude = buf.getInt();
this.latitude = buf.getInt();
buf.get(new byte[6]); // ?
// TODO which one is the time offset? Not sure it is the first
addNewGpsCoordinates();
final double longitudeDeg = convertHuamiValueToDecimalDegrees(longitude);
final double latitudeDeg = convertHuamiValueToDecimalDegrees(latitude);
//trace("Consumed GPS coords: {} {}", longitudeDeg, latitudeDeg);
}
private void consumeGpsDelta(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final short longitudeDelta = buf.getShort();
final short latitudeDelta = buf.getShort();
buf.getShort(); // ? seems to always be 2
this.longitude += longitudeDelta;
this.latitude += latitudeDelta;
if (lastActivityPoint == null) {
final String timestampStr = SDF.format(new Date(timestamp.getTime() + offset));
LOG.warn("{}: Got GPS delta before GPS coords, ignoring", timestampStr);
return;
}
addNewGpsCoordinates();
//trace("Consumed GPS delta: {} {}", longitudeDelta, latitudeDelta);
}
private void consumeStatus(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final int statusCode = buf.getShort();
final String status;
switch (statusCode) {
case 1:
status = "start";
break;
case 4:
status = "pause";
activityTrack.startNewSegment();
break;
case 5:
status = "resume";
activityTrack.startNewSegment();
break;
case 6:
status = "stop";
break;
default:
status = String.format("unknown (0x%X)", statusCode);
LOG.warn("Unknown status code {}", String.format("0x%X", statusCode));
}
// TODO split track into multiple segments?
//trace("Consumed Status: {}", status);
}
private void consumeSpeed(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final short cadence = buf.getShort(); // spm
final short stride = buf.getShort(); // cm
final short pace = buf.getShort(); // sec/km
// TODO integrate into gpx
//trace("Consumed speed: cadence={}, stride={}, ?={}", cadence, stride, );
}
private void consumeAltitude(final ByteBuffer buf) {
consumeTimestampOffset(buf);
altitude = (int) (buf.getInt() / 100.0f);
final ActivityPoint ap = getCurrentActivityPoint();
if (ap != null) {
final GPSCoordinate newCoordinate = new GPSCoordinate(
ap.getLocation().getLongitude(),
ap.getLocation().getLatitude(),
altitude
);
ap.setLocation(newCoordinate);
}
//trace("Consumed altitude: {}", altitude);
}
private void consumeHeartRate(final ByteBuffer buf) {
consumeTimestampOffset(buf);
final int heartRate = buf.get() & 0xff;
final ActivityPoint ap = getCurrentActivityPoint();
if (ap != null) {
ap.setHeartRate(heartRate);
}
//trace("Consumed HeartRate: {}", heartRate);
}
@Nullable
private ActivityPoint getCurrentActivityPoint() {
if (lastActivityPoint == null) {
return null;
}
// Round to the nearest second
final long currentTime = timestamp.getTime() + offset;
if (currentTime - lastActivityPoint.getTime().getTime() > 500) {
addNewGpsCoordinates();
return lastActivityPoint;
}
return lastActivityPoint;
}
private void addNewGpsCoordinates() {
final GPSCoordinate coordinate = new GPSCoordinate(
convertHuamiValueToDecimalDegrees(longitude),
convertHuamiValueToDecimalDegrees(latitude),
altitude
);
if (lastActivityPoint != null && lastActivityPoint.getLocation() != null && lastActivityPoint.getLocation().equals(coordinate)) {
// Ignore repeated location
return;
}
final ActivityPoint ap = new ActivityPoint(new Date(timestamp.getTime() + offset));
ap.setLocation(coordinate);
add(ap);
}
private void add(final ActivityPoint ap) {
if (ap == lastActivityPoint) {
LOG.debug("skipping point!");
return;
}
lastActivityPoint = ap;
activityTrack.addTrackPoint(ap);
}
private void trace(final String format, final Object... args) {
final Object[] argsWithDate = ArrayUtils.insert(0, args, SDF.format(new Date(timestamp.getTime() + offset)));
LOG.debug("{}: " + format, argsWithDate);
}
private enum Type {
TIMESTAMP(1, 12),
GPS_COORDS(2, 20),
GPS_DELTA(3, 8),
STATUS(4, 4),
SPEED(5, 8),
ALTITUDE(7, 6),
HEARTRATE(8, 3),
STRENGTH_SET(15, 34),
;
private final byte code;
private final int expectedLength;
Type(final int code, final int expectedLength) {
this.code = (byte) code;
this.expectedLength = expectedLength;
}
public byte getCode() {
return this.code;
}
public int getExpectedLength() {
return this.expectedLength;
}
public static Type fromCode(final byte code) {
for (final Type type : values()) {
if (type.getCode() == code) {
return type;
}
}
return null;
}
}
}