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 android.os.ParcelUuid;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport; 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.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Version; import nodomain.freeyourgadget.gadgetbridge.util.Version;
@@ -92,6 +95,17 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
return true; return true;
} }
@Override
public boolean supportsActivityTracks() {
return isHybridHR();
}
@Override
@Nullable
public ActivitySummaryParser getActivitySummaryParser(GBDevice device, Context context) {
return new HybridHRWorkoutSummaryParser();
}
@Override @Override
public boolean supportsUnicodeEmojis() { public boolean supportsUnicodeEmojis() {
return true; return true;
@@ -552,7 +552,8 @@ public class FossilWatchAdapter extends WatchAdapter {
public void handleFileData(byte[] fileData) { public void handleFileData(byte[] fileData) {
try (DBHandler dbHandler = GBApplication.acquireDB()) { try (DBHandler dbHandler = GBApplication.acquireDB()) {
ActivityFileParser parser = new ActivityFileParser(); 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()); HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()]; 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.HybridHRActivitySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRSpo2SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.HybridHRSpo2SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationHRConfiguration; 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.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
@@ -1253,9 +1255,9 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
Device device = DBHelper.getDevice(getDeviceSupport().getDevice(), dbHandler.getDaoSession()); Device device = DBHelper.getDevice(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
Long deviceId = device.getId(); Long deviceId = device.getId();
ActivityFileParser parser = new ActivityFileParser(); ActivityFileParser parser = new ActivityFileParser();
Map.Entry<ArrayList<ActivityEntry>, ArrayList<HybridHRSpo2Sample>> parsedEntries = parser.parseFile(fileData); parser.parseFile(fileData);
// Activities // Activities
ArrayList<ActivityEntry> entries = parsedEntries.getKey(); ArrayList<ActivityEntry> entries = parser.getActivitySamples();
HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession()); HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()]; HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()];
for (int i = 0; i < entries.size(); i++) { for (int i = 0; i < entries.size(); i++) {
@@ -1263,13 +1265,22 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
} }
provider.addGBActivitySamples(samples); provider.addGBActivitySamples(samples);
// SpO2, should be empty for an unsupported device // 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()); HybridHRSpo2SampleProvider spo2Provider = new HybridHRSpo2SampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
for (HybridHRSpo2Sample sample : spo2Samples) { for (HybridHRSpo2Sample sample : spo2Samples) {
sample.setDevice(device); sample.setDevice(device);
sample.setUser(user); sample.setUser(user);
} }
spo2Provider.addSamples(spo2Samples); 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) { if (saveRawActivityFiles) {
writeFile(String.valueOf(System.currentTimeMillis()), fileData); writeFile(String.valueOf(System.currentTimeMillis()), fileData);
@@ -1279,8 +1290,8 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
GB.signalActivityDataFinish(getDeviceSupport().getDevice()); GB.signalActivityDataFinish(getDeviceSupport().getDevice());
LOG.debug("Synchronized activity data"); LOG.debug("Synchronized activity data");
} catch (Exception ex) { } catch (Exception ex) {
GB.toast(getContext(), "Error saving steps data: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); GB.toast(getContext(), "Error saving activity data: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR);
LOG.error("Error saving steps data: ", ex); LOG.error("Error saving activity data: ", ex);
GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext());
} }
getDeviceSupport().getDevice().unsetBusyTask(); getDeviceSupport().getDevice().unsetBusyTask();
@@ -15,24 +15,38 @@
You should have received a copy of the GNU Affero General Public License 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/>. */ along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser; 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.ByteBuffer;
import java.nio.ByteOrder; import java.nio.ByteOrder;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Map; 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 { public class ActivityFileParser {
// state flags; // state flags;
int heartRateQuality; int heartRateQuality;
ActivityEntry.WEARING_STATE wearingState = ActivityEntry.WEARING_STATE.WEARING; ActivityEntry.WEARING_STATE wearingState = ActivityEntry.WEARING_STATE.WEARING;
int currentTimestamp = 0; // Aligns with `e2 04` from my testing int currentTimestamp = 0; // Aligns with `e2 04` from my testing
ActivityEntry currentSample = null; ActivityEntry currentSample = null;
int currentId = 1; int currentId = 1;
public Map.Entry<ArrayList<ActivityEntry>, ArrayList<HybridHRSpo2Sample>> parseFile(byte[] file) { ArrayList<ActivityEntry> samples = new ArrayList<>();
ArrayList<HybridHRSpo2Sample> spo2Samples = new ArrayList<>();
ArrayList<BaseActivitySummary> workouts = new ArrayList<>();
public void parseFile(byte[] file) {
ByteBuffer buffer = ByteBuffer.wrap(file); ByteBuffer buffer = ByteBuffer.wrap(file);
buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.order(ByteOrder.LITTLE_ENDIAN);
@@ -40,7 +54,7 @@ public class ActivityFileParser {
short version = buffer.getShort(2); short version = buffer.getShort(2);
if (version != 22) throw new RuntimeException("File version " + version + ", 16 required"); if (version != 22) throw new RuntimeException("File version " + version + ", 16 required");
this.currentTimestamp = buffer.getInt(8); this.currentTimestamp = buffer.getInt(8);
short timeOffsetMinutes = buffer.getShort(12); short timeOffsetMinutes = buffer.getShort(12);
@@ -48,8 +62,6 @@ public class ActivityFileParser {
buffer.position(52); // Seem to be another 32 bytes after the initial 20 stop 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); finishCurrentPacket(samples);
@@ -66,8 +78,8 @@ public class ActivityFileParser {
int timestamp = buffer.getInt(); int timestamp = buffer.getInt();
buffer.getShort(); // duration buffer.getShort(); // duration
buffer.getShort(); // minutes offset buffer.getShort(); // minutes offset
this.currentTimestamp = timestamp; this.currentTimestamp = timestamp;
} else if (f1 == (byte) 0xD3) { // Workout-related } else if (f1 == (byte) 0xD3) { // Workout-related
int hr1 = f2 & 0xFF; // Might be min HR during workout sometimes? int hr1 = f2 & 0xFF; // Might be min HR during workout sometimes?
byte[] infoB = new byte[2]; byte[] infoB = new byte[2];
@@ -78,55 +90,57 @@ public class ActivityFileParser {
if (v1 == (byte) 0xDF) { if (v1 == (byte) 0xDF) {
int hr2 = v2 & 0xFF; // Max HR during workout - extra data inside? int hr2 = v2 & 0xFF; // Max HR during workout - extra data inside?
buffer.get(); buffer.get();
if (infoB[0] == (byte) 0x08) if (infoB[0] == (byte) 0x08)
buffer.get(new byte[11]); // ? buffer.get(new byte[11]); // ?
else if (!elemValidFlags(buffer.get(buffer.position() + 4))) else if (!elemValidFlags(buffer.get(buffer.position() + 4)))
buffer.get(new byte[3]); buffer.get(new byte[3]);
} else if (v1 == (byte) 0xE2 && v2 == (byte) 0x04) { } else if (v1 == (byte) 0xE2 && v2 == (byte) 0x04) {
buffer.get(new byte[13]); buffer.get(new byte[13]);
if (!elemValidFlags(buffer.get(buffer.position()))) if (!elemValidFlags(buffer.get(buffer.position())))
buffer.get(new byte[3]); buffer.get(new byte[3]);
} else if (!elemValidFlags(buffer.get(buffer.position() + 4))) } else if (!elemValidFlags(buffer.get(buffer.position() + 4)))
buffer.get(); buffer.get();
} else if (f1 == (byte) 0xCF || f1 == (byte) 0xDF) { } else if (f1 == (byte) 0xCF || f1 == (byte) 0xDF) {
continue; // Not sure what to do with this continue; // Not sure what to do with this
} else if (f1 == (byte) 0xD6) { } else if (f1 == (byte) 0xD6) {
HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample(); HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample();
spo2Sample.setTimestamp(currentTimestamp * 1000L); spo2Sample.setTimestamp(currentTimestamp * 1000L);
spo2Sample.setSpo2(buffer.get() & 0xFF); spo2Sample.setSpo2(buffer.get() & 0xFF);
spo2Samples.add(spo2Sample); spo2Samples.add(spo2Sample);
buffer.get(new byte[3]); // Likely something to do with sample statistics buffer.get(new byte[3]); // Likely something to do with sample statistics
} else if (f1 == (byte) 0xFE && f2 == (byte) 0xFE) { } 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))) { } else if (elemValidFlags(buffer.get(buffer.position() + 2))) {
parseVariabilityBytes(f1, f2); parseVariabilityBytes(f1, f2);
int heartRate = buffer.get() & 0xFF; int heartRate = buffer.get() & 0xFF;
int calories = buffer.get() & 0xFF; int calories = buffer.get() & 0xFF;
boolean isActive = (calories & 0x40) == 0x40; boolean isActive = (calories & 0x40) == 0x40;
calories &= 0x3F; calories &= 0x3F;
currentSample.heartRate = heartRate; currentSample.heartRate = heartRate;
currentSample.calories = calories; currentSample.calories = calories;
currentSample.isActive = isActive; currentSample.isActive = isActive;
finishCurrentPacket(samples); finishCurrentPacket(samples);
continue; continue;
} }
if (buffer.position() > buffer.capacity() - 4) { if (buffer.position() > buffer.capacity() - 4) {
continue; continue;
} }
parseVariabilityBytes(buffer.get(), buffer.get()); parseVariabilityBytes(buffer.get(), buffer.get());
int heartRate = buffer.get() & 0xFF; int heartRate = buffer.get() & 0xFF;
@@ -140,81 +154,115 @@ public class ActivityFileParser {
finishCurrentPacket(samples); finishCurrentPacket(samples);
break; break;
case (byte) 0xC2: // Or `c2 X` `ac X` as per #2884 case (byte) 0xC2: // Or `c2 X` `ac X` as per #2884
buffer.get(new byte[3]); buffer.get(new byte[3]);
break; break;
case (byte) 0xE2: case (byte) 0xE2:
buffer.get(new byte[9]); buffer.get(new byte[9]);
if (!elemValidFlags(buffer.get(buffer.position()))) { if (!elemValidFlags(buffer.get(buffer.position()))) {
buffer.get(new byte[6]); buffer.get(new byte[6]);
} }
break; break;
case (byte) 0xE0: case (byte) 0xE0:
// Workout Info // Workout summary
for (int i = 0; i < 14; i++) { ByteArrayOutputStream rawWorkoutData = new ByteArrayOutputStream();
buffer.get(); // Attribute # ActivityKind gbType = ActivityKind.ACTIVITY;
byte size = buffer.get(); int duration = 0;
buffer.get(new byte[size & 0xFF]); // Can eventually use this, nowhere to pass for now try {
for (int i = 0; i < 14; i++) {
byte attributeId = buffer.get();
rawWorkoutData.write(attributeId);
byte size = buffer.get();
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; break;
case (byte) 0xDD: case (byte) 0xDD:
buffer.get(new byte[20]); // No idea what this is buffer.get(new byte[20]); // No idea what this is
break; break;
case (byte) 0xD6: case (byte) 0xD6:
buffer.get(); // Likely some statistic, notably different from 0xCE 0xD6 buffer.get(); // Likely some statistic, notably different from 0xCE 0xD6
HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample(); HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample();
spo2Sample.setTimestamp(currentTimestamp * 1000L); spo2Sample.setTimestamp(currentTimestamp * 1000L);
spo2Sample.setSpo2(buffer.get() & 0xFF); spo2Sample.setSpo2(buffer.get() & 0xFF);
spo2Samples.add(spo2Sample); spo2Samples.add(spo2Sample);
break; break;
case (byte) 0xCB: // Very rare, may even be removed case (byte) 0xCB: // Very rare, may even be removed
case (byte) 0xCC: // Around 73 or 74 case (byte) 0xCC: // Around 73 or 74
case (byte) 0xCF: // Almost always 128 (0x80) case (byte) 0xCF: // Almost always 128 (0x80)
buffer.get(); buffer.get();
break; break;
default: default:
; ;
} }
} }
return Map.entry(samples, spo2Samples);
} }
private static boolean elemValidFlags(byte value) { private static boolean elemValidFlags(byte value) {
for (byte i : new byte[]{(byte) 0xCE, (byte) 0xDD, (byte) 0xCB, (byte) 0xCC, (byte) 0xCF, (byte) 0xD6, (byte) 0xE2}) for (byte i : new byte[]{(byte) 0xCE, (byte) 0xDD, (byte) 0xCB, (byte) 0xCC, (byte) 0xCF, (byte) 0xD6, (byte) 0xE2})
if (value == i) if (value == i)
return true; return true;
return false; return false;
} }
private void parseVariabilityBytes(byte lower, byte higher){ private void parseVariabilityBytes(byte lower, byte higher) {
if((lower & 0b0000001) == 0b0000001){ if ((lower & 0b0000001) == 0b0000001) {
currentSample.maxVariability = (higher & 0b00000011) * 25 + 1; currentSample.maxVariability = (higher & 0b00000011) * 25 + 1;
currentSample.stepCount = lower & 0b1110; currentSample.stepCount = lower & 0b1110;
if((lower & 0b10000000) == 0b10000000){ if ((lower & 0b10000000) == 0b10000000) {
int factor = (lower >> 4) & 0b111; int factor = (lower >> 4) & 0b111;
currentSample.variability = 512 + factor * 64 + (higher >> 2 & 0b111111); currentSample.variability = 512 + factor * 64 + (higher >> 2 & 0b111111);
}else { } else {
currentSample.variability = lower & 0b01110000; currentSample.variability = lower & 0b01110000;
currentSample.variability <<= 2; currentSample.variability <<= 2;
currentSample.variability |= (higher >> 2) & 0b111111; currentSample.variability |= (higher >> 2) & 0b111111;
} }
}else{ } else {
currentSample.stepCount = lower & 0b11111110; currentSample.stepCount = lower & 0b11111110;
currentSample.variability = (int) higher * (int) higher * 64; currentSample.variability = (int) higher * (int) higher * 64;
currentSample.maxVariability = 10000; currentSample.maxVariability = 10000;
@@ -222,12 +270,12 @@ public class ActivityFileParser {
} }
private void parseWearByte(byte wearArg) { 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; if (wearBits == 0) this.wearingState = ActivityEntry.WEARING_STATE.NOT_WEARING;
else if (wearBits == 1) this.wearingState = ActivityEntry.WEARING_STATE.WEARING; else if (wearBits == 1) this.wearingState = ActivityEntry.WEARING_STATE.WEARING;
else this.wearingState = ActivityEntry.WEARING_STATE.UNKNOWN; else this.wearingState = ActivityEntry.WEARING_STATE.UNKNOWN;
byte heartRateQualityBits = (byte)((wearArg & 0b11100000) >> 5); byte heartRateQualityBits = (byte) ((wearArg & 0b11100000) >> 5);
this.heartRateQuality = heartRateQualityBits; this.heartRateQuality = heartRateQualityBits;
} }
@@ -243,4 +291,16 @@ public class ActivityFileParser {
this.currentSample = new ActivityEntry(); this.currentSample = new ActivityEntry();
this.currentSample.id = currentId++; 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); 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;
}
} }