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 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;
|
||||
|
||||
+2
-1
@@ -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()];
|
||||
|
||||
+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.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();
|
||||
|
||||
+72
-12
@@ -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) {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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