1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-26 02:25:50 +01:00

Garmin: add gpx import functionality for models that support it

Add a Field definition for GPS coordinates and remove the corresponding method from GarminUtils.
Add a new message COURSE and some fields to other known messages.

Also centralize some utility methods in GpxParser and GpxTrack, adapting ZeppOsGpxRouteFile.

Be aware that the capability used to identify the supported watches might be the wrong one.
This commit is contained in:
Daniele Gobbetti 2024-08-16 18:40:21 +02:00
parent f2f6536ea8
commit 29787d0c9b
22 changed files with 627 additions and 66 deletions

View File

@ -1,6 +1,10 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Collections;
import java.util.List;
@ -12,6 +16,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
@ -265,4 +270,14 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
return getPrefs(device).getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet())
.contains(capability.name());
}
@Nullable
@Override
public InstallHandler findInstallHandler(Uri uri, Context context) {
final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, context);
if (garminGpxRouteInstallHandler.isValid())
return garminGpxRouteInstallHandler;
return null;
}
}

View File

@ -0,0 +1,139 @@
/* Copyright (C) 2023-2024 Daniel Dakhno, 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import android.content.Context;
import android.net.Uri;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GpxRouteFileConverter;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
import static nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability.COURSE_DOWNLOAD;
public class GarminGpxRouteInstallHandler implements InstallHandler {
private static final Logger LOG = LoggerFactory.getLogger(GarminGpxRouteInstallHandler.class);
protected final Context mContext;
public byte[] rawBytes;
private GpxRouteFileConverter gpxRouteFileConverter;
public GarminGpxRouteInstallHandler(final Uri uri, final Context context) {
this.mContext = context;
final UriHelper uriHelper;
try {
uriHelper = UriHelper.get(uri, context);
} catch (final IOException e) {
LOG.error("Failed to get uri", e);
return;
}
try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB
final GpxRouteFileConverter gpxRouteFileConverter1 = new GpxRouteFileConverter(rawBytes);
if (gpxRouteFileConverter1.isValid()) {
this.gpxRouteFileConverter = gpxRouteFileConverter1;
}
} catch (final Exception e) {
LOG.error("Failed to read file", e);
}
}
@Override
public boolean isValid() {
return gpxRouteFileConverter != null;
}
@Override
public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
if (device.isBusy()) {
installActivity.setInfoText(device.getBusyTask());
installActivity.setInstallEnabled(false);
return;
}
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
if (!(coordinator instanceof GarminCoordinator)) {
LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass());
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
installActivity.setInstallEnabled(false);
return;
}
final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator;
if (!garminCoordinator.supports(device, COURSE_DOWNLOAD)) {
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
installActivity.setInstallEnabled(false);
return;
}
if (!device.isInitialized()) {
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
installActivity.setInstallEnabled(false);
return;
}
final GenericItem fwItem = createInstallItem(device);
fwItem.setIcon(coordinator.getDefaultIconResource());
if (gpxRouteFileConverter == null) {
fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
installActivity.setInstallEnabled(false);
return;
}
final StringBuilder builder = new StringBuilder();
final String gpxRoute = mContext.getString(R.string.kind_gpx_route);
builder.append(mContext.getString(R.string.fw_upgrade_notice, gpxRoute));
installActivity.setInfoText(builder.toString());
installActivity.setInstallItem(fwItem);
installActivity.setInstallEnabled(true);
}
@Override
public void onStartInstall(final GBDevice device) {
}
public GpxRouteFileConverter getGpxRouteFileConverter() {
return gpxRouteFileConverter;
}
private GenericItem createInstallItem(final GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
final String firmwareName = mContext.getString(
R.string.installhandler_firmware_name,
mContext.getString(coordinator.getDeviceNameResource()),
mContext.getString(R.string.kind_gpx_route),
gpxRouteFileConverter.getName()
);
return new GenericItem(firmwareName);
}
}

View File

@ -16,6 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import android.location.Location;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
@ -51,6 +53,34 @@ public class GPSCoordinate {
return altitude;
}
public double getDistance(GPSCoordinate source) {
final Location end = new Location("end");
end.setLatitude(this.getLatitude());
end.setLongitude(this.getLongitude());
final Location start = new Location("start");
start.setLatitude(source.getLatitude());
start.setLongitude(source.getLongitude());
return end.distanceTo(start);
}
public double getAltitudeDifference(GPSCoordinate source) {
if (this.getAltitude() == UNKNOWN_ALTITUDE)
return 0;
if (source.getAltitude() == UNKNOWN_ALTITUDE)
return 0;
return this.getAltitude() - source.getAltitude();
}
public double getAscent(GPSCoordinate source) {
return Math.max(0, this.getAltitudeDifference(source));
}
public double getDescent(GPSCoordinate source) {
return Math.max(0, -this.getAltitudeDifference(source));
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@ -17,6 +17,7 @@ import java.util.Locale;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.CreateFileMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FileTransferDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
@ -87,10 +88,10 @@ public class FileTransferHandler implements MessageHandler {
// return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
// }
//
// public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) {
// upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray));
// return new CreateFileMessage(fileAsByteArray.length, filetype);
// }
public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) {
upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray));
return new CreateFileMessage(fileAsByteArray.length, filetype);
}
public class Download {
@ -296,7 +297,7 @@ public class FileTransferHandler implements MessageHandler {
private FileTransferDataMessage take() {
final int currentOffset = this.dataHolder.position();
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())];
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize() - 13)]; //actual payload in FileTransferDataMessage
this.dataHolder.get(chunk);
setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
return new FileTransferDataMessage(chunk, currentOffset, getRunningCrc());

View File

@ -79,6 +79,7 @@ public class FileType {
FBT_PTD_BACKUP(128, 74),
// Other files
DOWNLOAD_COURSE(255, 4),
ERROR_SHUTDOWN_REPORTS(255, 245),
IQ_ERROR_REPORTS(255, 244),
ULF_LOGS(255, 247),

View File

@ -37,6 +37,7 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminGpxRouteInstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
@ -797,6 +798,14 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
}
}
@Override
public void onInstallApp(Uri uri) {
final GarminGpxRouteInstallHandler garminGpxRouteInstallHandler = new GarminGpxRouteInstallHandler(uri, getContext());
if (garminGpxRouteInstallHandler.isValid()) {
communicator.sendMessage("upload course file", fileTransferHandler.initiateUpload(garminGpxRouteInstallHandler.getGpxRouteFileConverter().getConvertedFile().getOutgoingMessage(), FileType.FILETYPE.DOWNLOAD_COURSE).getOutgoingMessage());
}
}
@Override
public void onTestNewFunction() {
parseAllFitFilesFromStorage();

View File

@ -10,10 +10,6 @@ public final class GarminUtils {
// utility class
}
public static double semicirclesToDegrees(final long semicircles) {
return semicircles * (180.0D / 0x80000000L);
}
public static GdiCore.CoreService.LocationData toLocationData(final Location location, final GdiCore.CoreService.DataType dataType) {
final GdiCore.CoreService.LatLon positionForWatch = GdiCore.CoreService.LatLon.newBuilder()
.setLat((int) ((location.getLatitude() * 2.147483648E9d) / 180.0d))

View File

@ -2,6 +2,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionAlarm;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionDayOfWeek;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionFileType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource;
@ -47,6 +48,8 @@ public class FieldDefinitionFactory {
return new FieldDefinitionSleepStage(localNumber, size, baseType, name);
case WEATHER_AQI:
return new FieldDefinitionWeatherAqi(localNumber, size, baseType, name);
case COORDINATE:
return new FieldDefinitionCoordinate(localNumber, size, baseType, name);
default:
return new FieldDefinition(localNumber, size, baseType, name);
}
@ -66,5 +69,6 @@ public class FieldDefinitionFactory {
LANGUAGE,
SLEEP_STAGE,
WEATHER_AQI,
COORDINATE
}
}

View File

@ -144,7 +144,12 @@ public class FitFile {
this.header.generateOutgoingDataPayload(writer);
writer.writeBytes(temporary.getBytes());
writer.writeShort(ChecksumCalculator.computeCrc(writer.getBytes(), this.header.getHeaderSize(), writer.getBytes().length - this.header.getHeaderSize()));
}
public byte[] getOutgoingMessage() {
final MessageWriter writer = new MessageWriter();
this.generateOutgoingDataPayload(writer);
return writer.getBytes();
}
@NonNull

View File

@ -91,8 +91,8 @@ public class GlobalFITMessage {
new FieldDefinitionPrimitive(0, BaseType.ENUM, "event"), // 9 lap
new FieldDefinitionPrimitive(1, BaseType.ENUM, "event_type"), // 1 stop
new FieldDefinitionPrimitive(2, BaseType.UINT32, "start_time"),
new FieldDefinitionPrimitive(3, BaseType.SINT32, "start_latitude"),
new FieldDefinitionPrimitive(4, BaseType.SINT32, "start_longitude"),
new FieldDefinitionPrimitive(3, BaseType.SINT32, "start_latitude", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(4, BaseType.SINT32, "start_longitude", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "sport"),
new FieldDefinitionPrimitive(6, BaseType.ENUM, "sub_sport"),
new FieldDefinitionPrimitive(7, BaseType.UINT32, "total_elapsed_time"), // with pauses
@ -106,16 +106,27 @@ public class GlobalFITMessage {
));
public static GlobalFITMessage LAP = new GlobalFITMessage(19, "LAP", Arrays.asList(
new FieldDefinitionPrimitive(3, BaseType.SINT32, "start_lat", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(4, BaseType.SINT32, "start_long", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(5, BaseType.SINT32, "end_lat", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(6, BaseType.SINT32, "end_long", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(7, BaseType.UINT32, "total_elapsed_time", 1000, 0), // s
new FieldDefinitionPrimitive(8, BaseType.UINT32, "total_timer_time", 1000, 0), // s
new FieldDefinitionPrimitive(9, BaseType.UINT32, "total_distance", 100, 0), // m
new FieldDefinitionPrimitive(21, BaseType.UINT16, "total_ascent"), // m
new FieldDefinitionPrimitive(22, BaseType.UINT16, "total_descent"), // m
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
public static GlobalFITMessage RECORD = new GlobalFITMessage(20, "RECORD", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.SINT32, "latitude"),
new FieldDefinitionPrimitive(1, BaseType.SINT32, "longitude"),
new FieldDefinitionPrimitive(0, BaseType.SINT32, "latitude", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(1, BaseType.SINT32, "longitude", FieldDefinitionFactory.FIELD.COORDINATE),
new FieldDefinitionPrimitive(2, BaseType.UINT16, "altitude", 5, 500), // m
new FieldDefinitionPrimitive(3, BaseType.UINT8, "heart_rate"),
new FieldDefinitionPrimitive(5, BaseType.UINT32, "distance", 100, 0), // m
new FieldDefinitionPrimitive(6, BaseType.UINT16, "speed", 1000, 0), // m/s
new FieldDefinitionPrimitive(73, BaseType.UINT32, "enhanced_speed"), // mm/s
new FieldDefinitionPrimitive(78, BaseType.UINT32, "enhanced_altitude"), // dm
new FieldDefinitionPrimitive(78, BaseType.UINT32, "enhanced_altitude", 5, 500), // m
new FieldDefinitionPrimitive(136, BaseType.UINT8, "wrist_heart_rate"),
new FieldDefinitionPrimitive(143, BaseType.UINT8, "body_battery"),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
@ -137,6 +148,11 @@ public class GlobalFITMessage {
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
public static GlobalFITMessage COURSE = new GlobalFITMessage(31, "COURSE", Arrays.asList(
new FieldDefinitionPrimitive(4, BaseType.ENUM, "sport"),
new FieldDefinitionPrimitive(5, BaseType.STRING, 16, "name")
));
public static GlobalFITMessage FILE_CREATOR = new GlobalFITMessage(49, "FILE_CREATOR", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT16, "software_version"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "hardware_version")
@ -282,6 +298,7 @@ public class GlobalFITMessage {
put(20, RECORD);
put(21, EVENT);
put(23, DEVICE_INFO);
put(31, COURSE);
put(49, FILE_CREATOR);
put(55, MONITORING);
put(127, CONNECTIVITY);

View File

@ -0,0 +1,196 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.enums.GarminSport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecordDataFactory;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrack;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackPoint;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackSegment;
public class GpxRouteFileConverter {
private static final Logger LOG = LoggerFactory.getLogger(GpxRouteFileConverter.class);
final double speed = 1.4; // m/s // TODO: make this configurable (and activity dependent?)
final int activity = GarminSport.RUN.getType(); //TODO: make this configurable
private final long timestamp;
private final GpxFile gpxFile;
private FitFile convertedFile;
private String name;
public GpxRouteFileConverter(byte[] xmlBytes) {
this.timestamp = System.currentTimeMillis() / 1000;
this.gpxFile = GpxParser.parseGpx(xmlBytes);
try {
this.convertedFile = convertGpxToRoute(gpxFile);
} catch (Exception e) {
LOG.error(e.getMessage());
this.convertedFile = null;
}
}
private static RecordData getFileCreatorRecordData() {
final RecordData fileCreatorRecord = FitRecordDataFactory.create(
new RecordDefinition(new RecordHeader((byte) 0x41), ByteOrder.BIG_ENDIAN, GlobalFITMessage.FILE_CREATOR, GlobalFITMessage.FILE_CREATOR.getFieldDefinitions(0), null),
new RecordHeader((byte) 0x01));
fileCreatorRecord.setFieldByName("software_version", 1);
return fileCreatorRecord;
}
public FitFile getConvertedFile() {
return convertedFile;
}
public boolean isValid() {
return this.convertedFile != null;
}
public String getName() {
if (gpxFile == null) {
return "";
}
if (!StringUtils.isNullOrEmpty(this.name))
return this.name;
if (!StringUtils.isNullOrEmpty(gpxFile.getName())) {
return gpxFile.getName();
} else {
return String.valueOf(timestamp);
}
}
private FitFile convertGpxToRoute(GpxFile gpxFile) {
if (gpxFile.getTracks().isEmpty()) {
LOG.error("Gpx file contains no Tracks.");
return null;
}
//GPX files may contain multiple tracks, we use only the first one
final GpxTrack track = gpxFile.getTracks().get(0);
if (track.getTrackSegments().isEmpty()) {
LOG.error("Gpx track contains no segment.");
return null;
}
//GPX track may contain multiple segments, we use only the first one
GpxTrackSegment gpxTrackSegment = track.getTrackSegments().get(0);
List<GpxTrackPoint> gpxTrackPointList = gpxTrackSegment.getTrackPoints();
if (gpxTrackPointList.isEmpty()) {
LOG.error("Gpx track segment contains no point");
return null;
}
this.name = track.getName();
final RecordHeader gpxDataPointRecordHeader = new RecordHeader((byte) 0x05);
final RecordDefinition gpxDataPointRecordDefinition = new RecordDefinition(new RecordHeader((byte) 0x45), ByteOrder.BIG_ENDIAN, GlobalFITMessage.RECORD, GlobalFITMessage.RECORD.getFieldDefinitions(0, 1, 2, 5, 253), null);
List<RecordData> gpxPointDataRecords = new ArrayList<>();
double totalAscent = 0;
double totalDescent = 0;
double totalDistance = 0;
long runningTs = timestamp;
GPSCoordinate prevPoint = gpxTrackPointList.get(0);
for (GPSCoordinate point :
gpxTrackPointList) {
totalAscent += point.getAscent(prevPoint);
totalDescent += point.getDescent(prevPoint);
totalDistance += point.getDistance(prevPoint);
runningTs += (long) (point.getDistance(prevPoint) / speed);
final RecordData gpxDataPointRecord = FitRecordDataFactory.create(gpxDataPointRecordDefinition, gpxDataPointRecordHeader);
gpxDataPointRecord.setFieldByName("latitude", point.getLatitude());
gpxDataPointRecord.setFieldByName("longitude", point.getLongitude());
gpxDataPointRecord.setFieldByName("altitude", point.getAltitude());
gpxDataPointRecord.setFieldByName("distance", totalDistance);
gpxDataPointRecord.setFieldByName("timestamp", runningTs);
prevPoint = point;
gpxPointDataRecords.add(gpxDataPointRecord);
}
final RecordData lapRecord = getLapRecordData(gpxTrackPointList);
lapRecord.setFieldByName("total_distance", totalDistance);
lapRecord.setFieldByName("total_ascent", totalAscent);
lapRecord.setFieldByName("total_descent", totalDescent);
lapRecord.setFieldByName("total_elapsed_time", (runningTs - timestamp));
lapRecord.setFieldByName("total_timer_time", (runningTs - timestamp));
final List<RecordData> courseFileDataRecords = new ArrayList<>();
courseFileDataRecords.add(getFileIdRecordData());
courseFileDataRecords.add(getFileCreatorRecordData());
courseFileDataRecords.add(getCourseRecordData());
courseFileDataRecords.add(lapRecord);
final RecordHeader eventRecordHeader = new RecordHeader((byte) 0x04);
final RecordDefinition eventRecordDefinition = new RecordDefinition(new RecordHeader((byte) 0x44), ByteOrder.BIG_ENDIAN, GlobalFITMessage.EVENT, GlobalFITMessage.EVENT.getFieldDefinitions(0, 1, 4, 253), null);
courseFileDataRecords.add(getEventRecordData(eventRecordDefinition, eventRecordHeader, timestamp, 0));
courseFileDataRecords.add(getEventRecordData(eventRecordDefinition, eventRecordHeader, runningTs, 9));
courseFileDataRecords.addAll(gpxPointDataRecords);
return new FitFile(courseFileDataRecords);
}
private RecordData getEventRecordData(RecordDefinition eventRecordDefinition, RecordHeader eventRecordHeader, long timestamp, int eventType) {
final RecordData startEvent = FitRecordDataFactory.create(
eventRecordDefinition,
eventRecordHeader);
startEvent.setFieldByName("timestamp", timestamp);
startEvent.setFieldByName("event", 0);
startEvent.setFieldByName("event_group", 0);
startEvent.setFieldByName("event_type", eventType);
return startEvent;
}
private RecordData getLapRecordData(List<GpxTrackPoint> gpxTrackPointList) {
final GPSCoordinate first = gpxTrackPointList.get(0);
final GPSCoordinate last = gpxTrackPointList.get(gpxTrackPointList.size() - 1);
final RecordData lapRecord = FitRecordDataFactory.create(
new RecordDefinition(new RecordHeader((byte) 0x43), ByteOrder.BIG_ENDIAN, GlobalFITMessage.LAP, GlobalFITMessage.LAP.getFieldDefinitions(3, 4, 5, 6, 7, 8, 9, 21, 22, 253), null),
new RecordHeader((byte) 0x03));
lapRecord.setFieldByName("start_lat", first.getLatitude());
lapRecord.setFieldByName("start_long", first.getLongitude());
lapRecord.setFieldByName("end_lat", last.getLatitude());
lapRecord.setFieldByName("end_long", last.getLongitude());
lapRecord.setFieldByName("timestamp", timestamp);
return lapRecord;
}
private RecordData getCourseRecordData() {
final RecordData courseRecord = FitRecordDataFactory.create(
new RecordDefinition(new RecordHeader((byte) 0x42), ByteOrder.BIG_ENDIAN, GlobalFITMessage.COURSE, GlobalFITMessage.COURSE.getFieldDefinitions(4, 5), null),
new RecordHeader((byte) 0x02));
courseRecord.setFieldByName("sport", activity); //TODO use track.getType()
courseRecord.setFieldByName("name", this.getName());
return courseRecord;
}
private RecordData getFileIdRecordData() {
final RecordData fileIdRecord = FitRecordDataFactory.create(
new RecordDefinition(new RecordHeader((byte) 0x40), ByteOrder.BIG_ENDIAN, GlobalFITMessage.FILE_ID, GlobalFITMessage.FILE_ID.getFieldDefinitions(0, 1, 2, 3, 4, 5), null),
new RecordHeader((byte) 0x00));
fileIdRecord.setFieldByName("type", FileType.FILETYPE.COURSES.getSubType());
fileIdRecord.setFieldByName("manufacturer", 1);
fileIdRecord.setFieldByName("product", 65534);
fileIdRecord.setFieldByName("time_created", timestamp);
fileIdRecord.setFieldByName("serial_number", 1);
fileIdRecord.setFieldByName("number", 1);
return fileIdRecord;
}
}

View File

@ -69,7 +69,7 @@ public class RecordHeader {
public byte generateOutgoingDataPayload() { //TODO: unclear if correct
if (!definition && !developerData) {
assert timeOffset != null;
if (timeOffset != null)
return (byte) (timeOffset | (((byte) localMessageType) << 5));
}
byte base = (byte) localMessageType;

View File

@ -252,6 +252,8 @@ public class FitCodeGen {
return FieldDefinitionSleepStage.SleepStage.class;
case WEATHER_AQI:
return FieldDefinitionWeatherAqi.AQI_LEVELS.class;
case COORDINATE:
return Double.class;
}
throw new RuntimeException("Unknown field type " + primitive.getType());

View File

@ -0,0 +1,27 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionCoordinate extends FieldDefinition {
final double conversionFactor = (180.0D / 0x80000000L);
public FieldDefinitionCoordinate(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
return ((long) baseType.decode(byteBuffer, 1, 0)) * conversionFactor;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
baseType.encode(byteBuffer, (int) Math.round((double) o / conversionFactor), 1, 0);
}
}

View File

@ -0,0 +1,32 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
import androidx.annotation.Nullable;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader;
//
// WARNING: This class was auto-generated, please avoid modifying it directly.
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
//
public class FitCourse extends RecordData {
public FitCourse(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
super(recordDefinition, recordHeader);
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
if (globalNumber != 31) {
throw new IllegalArgumentException("FitCourse expects global messages of " + 31 + ", got " + globalNumber);
}
}
@Nullable
public Integer getSport() {
return (Integer) getFieldByNumber(4);
}
@Nullable
public String getName() {
return (String) getFieldByNumber(5);
}
}

View File

@ -20,6 +20,51 @@ public class FitLap extends RecordData {
}
}
@Nullable
public Double getStartLat() {
return (Double) getFieldByNumber(3);
}
@Nullable
public Double getStartLong() {
return (Double) getFieldByNumber(4);
}
@Nullable
public Double getEndLat() {
return (Double) getFieldByNumber(5);
}
@Nullable
public Double getEndLong() {
return (Double) getFieldByNumber(6);
}
@Nullable
public Long getTotalElapsedTime() {
return (Long) getFieldByNumber(7);
}
@Nullable
public Long getTotalTimerTime() {
return (Long) getFieldByNumber(8);
}
@Nullable
public Long getTotalDistance() {
return (Long) getFieldByNumber(9);
}
@Nullable
public Integer getTotalAscent() {
return (Integer) getFieldByNumber(21);
}
@Nullable
public Integer getTotalDescent() {
return (Integer) getFieldByNumber(22);
}
@Nullable
public Long getTimestamp() {
return (Long) getFieldByNumber(253);

View File

@ -26,13 +26,18 @@ public class FitRecord extends RecordData {
}
@Nullable
public Long getLatitude() {
return (Long) getFieldByNumber(0);
public Double getLatitude() {
return (Double) getFieldByNumber(0);
}
@Nullable
public Long getLongitude() {
return (Long) getFieldByNumber(1);
public Double getLongitude() {
return (Double) getFieldByNumber(1);
}
@Nullable
public Integer getAltitude() {
return (Integer) getFieldByNumber(2);
}
@Nullable
@ -45,6 +50,11 @@ public class FitRecord extends RecordData {
return (Long) getFieldByNumber(5);
}
@Nullable
public Integer getSpeed() {
return (Integer) getFieldByNumber(6);
}
@Nullable
public Long getEnhancedSpeed() {
return (Long) getFieldByNumber(73);
@ -77,9 +87,9 @@ public class FitRecord extends RecordData {
activityPoint.setTime(new Date(getComputedTimestamp()));
if (getLatitude() != null && getLongitude() != null) {
activityPoint.setLocation(new GPSCoordinate(
GarminUtils.semicirclesToDegrees(getLongitude().longValue()),
GarminUtils.semicirclesToDegrees(getLatitude().longValue()),
getEnhancedAltitude() != null ? getEnhancedAltitude() / 10d : GPSCoordinate.UNKNOWN_ALTITUDE
getLongitude(),
getLatitude(),
getEnhancedAltitude() != null ? getEnhancedAltitude() : GPSCoordinate.UNKNOWN_ALTITUDE
));
}
if (getHeartRate() != null) {

View File

@ -37,6 +37,8 @@ public class FitRecordDataFactory {
return new FitEvent(recordDefinition, recordHeader);
case 23:
return new FitDeviceInfo(recordDefinition, recordHeader);
case 31:
return new FitCourse(recordDefinition, recordHeader);
case 49:
return new FitFileCreator(recordDefinition, recordHeader);
case 55:

View File

@ -18,12 +18,9 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operat
import android.location.Location;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
@ -31,9 +28,7 @@ import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackPoint;
@ -44,15 +39,6 @@ public class ZeppOsGpxRouteFile {
private static final double COORD_MULTIPLIER = 3000000.0;
public static final byte[] XML_HEADER = new byte[]{
'<', '?', 'x', 'm', 'l'
};
// Some gpx files start with "<gpx" directly.. this needs to be improved
public static final byte[] GPX_START = new byte[]{
'<', 'g', 'p', 'x'
};
private final byte[] xmlBytes;
private final long timestamp;
private final GpxFile gpxFile;
@ -60,36 +46,13 @@ public class ZeppOsGpxRouteFile {
public ZeppOsGpxRouteFile(final byte[] xmlBytes) {
this.xmlBytes = xmlBytes;
this.timestamp = System.currentTimeMillis() / 1000;
this.gpxFile = parseGpx(xmlBytes);
this.gpxFile = GpxParser.parseGpx(xmlBytes);
}
public boolean isValid() {
return this.gpxFile != null;
}
@Nullable
public static GpxFile parseGpx(final byte[] xmlBytes) {
if (!isGpxFile(xmlBytes)) {
return null;
}
try (ByteArrayInputStream bais = new ByteArrayInputStream(xmlBytes)) {
final GpxParser gpxParser = new GpxParser(bais);
return gpxParser.getGpxFile();
} catch (final IOException e) {
LOG.error("Failed to read xml", e);
} catch (final GpxParseException e) {
LOG.error("Failed to parse gpx", e);
}
return null;
}
public static boolean isGpxFile(final byte[] data) {
// TODO improve this
return ArrayUtils.equals(data, XML_HEADER, 0) || ArrayUtils.equals(data, GPX_START, 0);
}
public long getTimestamp() {
return timestamp;
}
@ -108,7 +71,6 @@ public class ZeppOsGpxRouteFile {
public byte[] getEncodedBytes() {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
final GpxFile gpxFile = parseGpx(xmlBytes);
if (gpxFile == null) {
LOG.error("Failed to read gpx file - this should never happen");
return null;

View File

@ -16,6 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.gpx;
import androidx.annotation.Nullable;
import com.google.gson.internal.bind.util.ISO8601Utils;
import org.slf4j.Logger;
@ -24,12 +26,14 @@ import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParsePosition;
import java.util.Date;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrack;
import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxTrackPoint;
@ -39,11 +43,44 @@ import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxWaypoint;
public class GpxParser {
private static final Logger LOG = LoggerFactory.getLogger(GpxParser.class);
public static final byte[] XML_HEADER = new byte[]{
'<', '?', 'x', 'm', 'l'
};
// Some gpx files start with "<gpx" directly.. this needs to be improved
public static final byte[] GPX_START = new byte[]{
'<', 'g', 'p', 'x'
};
private final XmlPullParser parser;
private int eventType;
private final GpxFile.Builder fileBuilder;
@Nullable
public static GpxFile parseGpx(final byte[] xmlBytes) {
if (!isGpxFile(xmlBytes)) {
return null;
}
try (ByteArrayInputStream bais = new ByteArrayInputStream(xmlBytes)) {
final GpxParser gpxParser = new GpxParser(bais);
return gpxParser.getGpxFile();
} catch (final IOException e) {
LOG.error("Failed to read xml", e);
} catch (final GpxParseException e) {
LOG.error("Failed to parse gpx", e);
}
return null;
}
public static boolean isGpxFile(final byte[] data) {
// TODO improve this
return ArrayUtils.equals(data, XML_HEADER, 0) || ArrayUtils.equals(data, GPX_START, 0);
}
public GpxParser(final InputStream stream) throws GpxParseException {
this.fileBuilder = new GpxFile.Builder();
@ -98,6 +135,12 @@ public class GpxParser {
while (eventType != XmlPullParser.END_TAG || !parser.getName().equals("trk")) {
if (eventType == XmlPullParser.START_TAG) {
switch (parser.getName()) {
case "name":
trackBuilder.withName(parseStringContent("name"));
continue;
case "type":
trackBuilder.withType(parseStringContent("type"));
continue;
case "trkseg":
final GpxTrackSegment segment = parseTrackSegment();
if (!segment.getTrackPoints().isEmpty()) {

View File

@ -20,12 +20,25 @@ import java.util.ArrayList;
import java.util.List;
public class GpxTrack {
private final String name;
private final String type;
private final List<GpxTrackSegment> trackSegments;
public GpxTrack(final List<GpxTrackSegment> trackSegments) {
public GpxTrack(String name, String type, final List<GpxTrackSegment> trackSegments) {
this.name = name;
this.type = type;
this.trackSegments = trackSegments;
}
public String getName() {
return name;
}
public String getType() {
return type;
}
public List<GpxTrackSegment> getTrackSegments() {
return trackSegments;
}
@ -41,15 +54,27 @@ public class GpxTrack {
}
public static class Builder {
private String name;
private String type;
private final List<GpxTrackSegment> trackSegments = new ArrayList<>();
public Builder withName(final String name) {
this.name = name;
return this;
}
public Builder withType(final String type) {
this.type = type;
return this;
}
public Builder withTrackSegment(final GpxTrackSegment trackSegment) {
trackSegments.add(trackSegment);
return this;
}
public GpxTrack build() {
return new GpxTrack(trackSegments);
return new GpxTrack(name, type, trackSegments);
}
}
}

View File

@ -364,9 +364,9 @@ public class GarminSupportTest extends TestBase {
"FitFileId{manufacturer=15, type=ACTIVITY, product=9001, serial_number=1701}, " +
"FitDeveloperData{application_id=[1,1,2,3,5,8,13,21,34,55,89,144,233,121,98,219], developer_data_index=0}, " +
"FitFieldDescription{developer_data_index=0, field_definition_number=0, fit_base_type_id=1, field_name=doughnuts_earned, units=doughnuts}, " +
"FitRecord{heart_rate=140, unknown_4(UINT8/1)=88, distance=510, unknown_6(UINT16/2)=47488, doughnuts_earned=1}, " +
"FitRecord{heart_rate=143, unknown_4(UINT8/1)=90, distance=2080, unknown_6(UINT16/2)=36416, doughnuts_earned=2}, " +
"FitRecord{heart_rate=144, unknown_4(UINT8/1)=92, distance=3710, unknown_6(UINT16/2)=35344, doughnuts_earned=3}" +
"FitRecord{heart_rate=140, unknown_4(UINT8/1)=88, distance=510, speed=47, doughnuts_earned=1}, " +
"FitRecord{heart_rate=143, unknown_4(UINT8/1)=90, distance=2080, speed=36, doughnuts_earned=2}, " +
"FitRecord{heart_rate=144, unknown_4(UINT8/1)=92, distance=3710, speed=35, doughnuts_earned=3}" +
"]";
FitFile fitFile = FitFile.parseIncoming(fileContents);