mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
+14
@@ -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;
|
||||||
|
|||||||
+2
-1
@@ -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()];
|
||||||
|
|||||||
+16
-5
@@ -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();
|
||||||
|
|||||||
+125
-65
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user