Fossil/Skagen Hybrids: Add support for workouts sync

Fixes #4639
This commit is contained in:
Arjan Schrijver
2025-07-15 09:45:05 +02:00
parent 8daa0178ce
commit 310027cdb1
6 changed files with 255 additions and 71 deletions
@@ -25,6 +25,7 @@ import android.net.Uri;
import android.os.ParcelUuid;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -49,11 +50,13 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser.HybridHRWorkoutSummaryParser;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Version;
@@ -92,6 +95,17 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
return true;
}
@Override
public boolean supportsActivityTracks() {
return isHybridHR();
}
@Override
@Nullable
public ActivitySummaryParser getActivitySummaryParser(GBDevice device, Context context) {
return new HybridHRWorkoutSummaryParser();
}
@Override
public boolean supportsUnicodeEmojis() {
return true;
@@ -552,7 +552,8 @@ public class FossilWatchAdapter extends WatchAdapter {
public void handleFileData(byte[] fileData) {
try (DBHandler dbHandler = GBApplication.acquireDB()) {
ActivityFileParser parser = new ActivityFileParser();
ArrayList<ActivityEntry> entries = parser.parseFile(fileData).getKey();
parser.parseFile(fileData);
ArrayList<ActivityEntry> entries = parser.getActivitySamples();
HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()];
@@ -111,6 +111,8 @@ import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilHRInstallHandl
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRActivitySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRSpo2SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationHRConfiguration;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
@@ -1253,9 +1255,9 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
Device device = DBHelper.getDevice(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
Long deviceId = device.getId();
ActivityFileParser parser = new ActivityFileParser();
Map.Entry<ArrayList<ActivityEntry>, ArrayList<HybridHRSpo2Sample>> parsedEntries = parser.parseFile(fileData);
parser.parseFile(fileData);
// Activities
ArrayList<ActivityEntry> entries = parsedEntries.getKey();
ArrayList<ActivityEntry> entries = parser.getActivitySamples();
HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()];
for (int i = 0; i < entries.size(); i++) {
@@ -1263,13 +1265,22 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
}
provider.addGBActivitySamples(samples);
// SpO2, should be empty for an unsupported device
ArrayList<HybridHRSpo2Sample> spo2Samples = parsedEntries.getValue();
ArrayList<HybridHRSpo2Sample> spo2Samples = parser.getSpo2Samples();
HybridHRSpo2SampleProvider spo2Provider = new HybridHRSpo2SampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
for (HybridHRSpo2Sample sample : spo2Samples) {
sample.setDevice(device);
sample.setUser(user);
}
spo2Provider.addSamples(spo2Samples);
// Workout summaries
ArrayList<BaseActivitySummary> workoutSummaries = parser.getWorkoutSummaries();
LOG.debug("WORKOUT SUMMARIES FOUND: {}", workoutSummaries);
BaseActivitySummaryDao summaryDao = provider.getSession().getBaseActivitySummaryDao();
for (BaseActivitySummary summary : workoutSummaries) {
summary.setDevice(device);
summary.setUser(user);
summaryDao.insert(summary);
}
if (saveRawActivityFiles) {
writeFile(String.valueOf(System.currentTimeMillis()), fileData);
@@ -1279,8 +1290,8 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
GB.signalActivityDataFinish(getDeviceSupport().getDevice());
LOG.debug("Synchronized activity data");
} catch (Exception ex) {
GB.toast(getContext(), "Error saving steps data: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
LOG.error("Error saving steps data: ", ex);
GB.toast(getContext(), "Error saving activity data: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
LOG.error("Error saving activity data: ", ex);
GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext());
}
getDeviceSupport().getDevice().unsetBusyTask();
@@ -15,13 +15,23 @@
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.service.devices.qhybrid.parser;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
public class ActivityFileParser {
// state flags;
@@ -31,8 +41,12 @@ public class ActivityFileParser {
ActivityEntry currentSample = null;
int currentId = 1;
ArrayList<ActivityEntry> samples = new ArrayList<>();
ArrayList<HybridHRSpo2Sample> spo2Samples = new ArrayList<>();
ArrayList<BaseActivitySummary> workouts = new ArrayList<>();
public Map.Entry<ArrayList<ActivityEntry>, ArrayList<HybridHRSpo2Sample>> parseFile(byte[] file) {
public void parseFile(byte[] file) {
ByteBuffer buffer = ByteBuffer.wrap(file);
buffer.order(ByteOrder.LITTLE_ENDIAN);
@@ -48,8 +62,6 @@ public class ActivityFileParser {
buffer.position(52); // Seem to be another 32 bytes after the initial 20 stop
ArrayList<ActivityEntry> samples = new ArrayList<>();
ArrayList<HybridHRSpo2Sample> spo2Samples = new ArrayList<>();
finishCurrentPacket(samples);
@@ -107,7 +119,9 @@ public class ActivityFileParser {
buffer.get(new byte[3]); // Likely something to do with sample statistics
} else if (f1 == (byte) 0xFE && f2 == (byte) 0xFE) {
if (buffer.get(buffer.position()) == (byte) 0xFE) { buffer.get(); } // WHY?
if (buffer.get(buffer.position()) == (byte) 0xFE) {
buffer.get();
} // WHY?
} else if (elemValidFlags(buffer.get(buffer.position() + 2))) {
parseVariabilityBytes(f1, f2);
@@ -156,12 +170,50 @@ public class ActivityFileParser {
break;
case (byte) 0xE0:
// Workout Info
// Workout summary
ByteArrayOutputStream rawWorkoutData = new ByteArrayOutputStream();
ActivityKind gbType = ActivityKind.ACTIVITY;
int duration = 0;
try {
for (int i = 0; i < 14; i++) {
buffer.get(); // Attribute #
byte attributeId = buffer.get();
rawWorkoutData.write(attributeId);
byte size = buffer.get();
buffer.get(new byte[size & 0xFF]); // Can eventually use this, nowhere to pass for now
rawWorkoutData.write(size);
byte[] rawInfo = new byte[size & 0xFF];
buffer.get(rawInfo);
ByteBuffer info = ByteBuffer.wrap(rawInfo).order(ByteOrder.LITTLE_ENDIAN);
rawWorkoutData.write(info.array());
if (attributeId == 2) { // Attribute 2 is the duration in seconds
duration = info.getInt();
}
if (attributeId == 9) { // Attribute 9 is the workout type
gbType = switch (info.get(0)) {
case 0x01 -> ActivityKind.RUNNING;
case 0x02 -> ActivityKind.CYCLING;
case 0x03 -> ActivityKind.TREADMILL;
case 0x04 -> ActivityKind.CROSS_TRAINER;
case 0x05 -> ActivityKind.WEIGHTLIFTING;
case 0x06 -> ActivityKind.TRAINING;
case 0x08 -> ActivityKind.WALKING;
case 0x09 -> ActivityKind.ROWING_MACHINE;
case 0x0c -> ActivityKind.HIKING;
case 0x0d -> ActivityKind.SPINNING;
default -> gbType;
};
}
}
} catch (IOException ignored) {
break;
}
BaseActivitySummary summary = new BaseActivitySummary();
summary.setName(gbType.name());
summary.setActivityKind(gbType.getCode());
summary.setStartTime(DateTimeUtils.parseTimeStamp(currentTimestamp - duration));
summary.setEndTime(DateTimeUtils.parseTimeStamp(currentTimestamp));
summary.setRawSummaryData(rawWorkoutData.toByteArray());
workouts.add(summary);
break;
@@ -187,11 +239,7 @@ public class ActivityFileParser {
default:
;
}
}
return Map.entry(samples, spo2Samples);
}
private static boolean elemValidFlags(byte value) {
@@ -202,19 +250,19 @@ public class ActivityFileParser {
return false;
}
private void parseVariabilityBytes(byte lower, byte higher){
if((lower & 0b0000001) == 0b0000001){
private void parseVariabilityBytes(byte lower, byte higher) {
if ((lower & 0b0000001) == 0b0000001) {
currentSample.maxVariability = (higher & 0b00000011) * 25 + 1;
currentSample.stepCount = lower & 0b1110;
if((lower & 0b10000000) == 0b10000000){
if ((lower & 0b10000000) == 0b10000000) {
int factor = (lower >> 4) & 0b111;
currentSample.variability = 512 + factor * 64 + (higher >> 2 & 0b111111);
}else {
} else {
currentSample.variability = lower & 0b01110000;
currentSample.variability <<= 2;
currentSample.variability |= (higher >> 2) & 0b111111;
}
}else{
} else {
currentSample.stepCount = lower & 0b11111110;
currentSample.variability = (int) higher * (int) higher * 64;
currentSample.maxVariability = 10000;
@@ -222,12 +270,12 @@ public class ActivityFileParser {
}
private void parseWearByte(byte wearArg) {
byte wearBits = (byte)((wearArg & 0b00011000) >> 3);
byte wearBits = (byte) ((wearArg & 0b00011000) >> 3);
if (wearBits == 0) this.wearingState = ActivityEntry.WEARING_STATE.NOT_WEARING;
else if (wearBits == 1) this.wearingState = ActivityEntry.WEARING_STATE.WEARING;
else this.wearingState = ActivityEntry.WEARING_STATE.UNKNOWN;
byte heartRateQualityBits = (byte)((wearArg & 0b11100000) >> 5);
byte heartRateQualityBits = (byte) ((wearArg & 0b11100000) >> 5);
this.heartRateQuality = heartRateQualityBits;
}
@@ -243,4 +291,16 @@ public class ActivityFileParser {
this.currentSample = new ActivityEntry();
this.currentSample.id = currentId++;
}
public ArrayList<ActivityEntry> getActivitySamples() {
return samples;
}
public ArrayList<HybridHRSpo2Sample> getSpo2Samples() {
return spo2Samples;
}
public ArrayList<BaseActivitySummary> getWorkoutSummaries() {
return workouts;
}
}
@@ -0,0 +1,81 @@
/* Copyright (C) 2025 Arjan Schrijver
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.service.devices.qhybrid.parser;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
public class HybridHRWorkoutSummaryParser implements ActivitySummaryParser {
@Override
public BaseActivitySummary parseBinaryData(BaseActivitySummary summary, boolean forDetails) {
final byte[] rawSummaryData = summary.getRawSummaryData();
if (rawSummaryData == null) {
return summary;
}
final ByteBuffer buffer = ByteBuffer.wrap(rawSummaryData).order(ByteOrder.LITTLE_ENDIAN);
final ActivitySummaryData summaryData = new ActivitySummaryData();
for (int i = 0; i < 14; i++) {
byte attributeId = buffer.get();
byte size = buffer.get();
byte[] rawInfo = new byte[size & 0xFF];
buffer.get(rawInfo);
ByteBuffer info = ByteBuffer.wrap(rawInfo).order(ByteOrder.LITTLE_ENDIAN);
if (ArrayUtils.isAllFF(info.array(), 0, size)) {
continue;
}
switch (attributeId) {
case 2:
int duration = info.getInt();
summaryData.add(ActivitySummaryEntries.ACTIVE_SECONDS, duration, ActivitySummaryEntries.UNIT_SECONDS);
break;
case 4:
int steps = info.getInt();
summaryData.add(ActivitySummaryEntries.STEPS, steps, ActivitySummaryEntries.UNIT_STEPS);
break;
case 5:
int distance = info.getInt() / 100;
summaryData.add(ActivitySummaryEntries.DISTANCE_METERS, distance, ActivitySummaryEntries.UNIT_METERS);
break;
case 6:
int calories = info.getInt();
summaryData.add(ActivitySummaryEntries.CALORIES_BURNT, calories, ActivitySummaryEntries.UNIT_KCAL);
break;
case 7:
int avg_hr = info.get(0) & 0xFF;
summaryData.add(ActivitySummaryEntries.HR_AVG, avg_hr, ActivitySummaryEntries.UNIT_BPM);
break;
case 8:
int max_hr = info.get(0) & 0xFF;
summaryData.add(ActivitySummaryEntries.HR_MAX, max_hr, ActivitySummaryEntries.UNIT_BPM);
break;
}
}
summary.setSummaryData(summaryData.toString());
return summary;
}
}
@@ -150,4 +150,21 @@ public class ArrayUtils {
{
return isAllZeros(array, 0, array.length);
}
/**
* Check if a byte array contains only 0xFF values
* @param array The array to check
* @param startIndex The starting position
* @param length Number of elements to check
* @return true if all checked elements were == 0xFF, false otherwise
*/
public static boolean isAllFF(byte[] array, int startIndex, int length)
{
for(int i = startIndex; i < startIndex + length; i++)
{
if ((array[i] & 0xFF) != 0xFF)
return false;
}
return true;
}
}