Fossil/Skagen Gen. 6 Hybrids: Add SpO2 Support

This commit is contained in:
Benjamin Swartley 2024-11-30 15:45:34 -05:00
parent b3f29d16b3
commit 4f30648886
6 changed files with 114 additions and 15 deletions

View File

@ -54,7 +54,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
final Schema schema = new Schema(89, MAIN_PACKAGE + ".entities"); final Schema schema = new Schema(90, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema); Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes); Entity user = addUserInfo(schema, userAttributes);
@ -115,6 +115,7 @@ public class GBDaoGenerator {
addPineTimeActivitySample(schema, user, device); addPineTimeActivitySample(schema, user, device);
addWithingsSteelHRActivitySample(schema, user, device); addWithingsSteelHRActivitySample(schema, user, device);
addHybridHRActivitySample(schema, user, device); addHybridHRActivitySample(schema, user, device);
addHybridHRSpo2Sample(schema, user, device);
addVivomoveHrActivitySample(schema, user, device); addVivomoveHrActivitySample(schema, user, device);
addGarminFitFile(schema, user, device); addGarminFitFile(schema, user, device);
addGarminActivitySample(schema, user, device); addGarminActivitySample(schema, user, device);
@ -734,6 +735,13 @@ public class GBDaoGenerator {
return activitySample; return activitySample;
} }
private static Entity addHybridHRSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "HybridHRSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
private static Entity addCyclingSample(Schema schema, Entity user, Entity device) { private static Entity addCyclingSample(Schema schema, Entity user, Entity device) {
Entity cyclingSample = addEntity(schema, "CyclingSample"); Entity cyclingSample = addEntity(schema, "CyclingSample");
addCommonTimeSampleProperties("AbstractTimeSample", cyclingSample, user, device); addCommonTimeSampleProperties("AbstractTimeSample", cyclingSample, user, device);

View File

@ -0,0 +1,56 @@
/* Copyright (C) 2024 Benjamin Swartley
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.qhybrid;
import androidx.annotation.NonNull;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class HybridHRSpo2SampleProvider extends AbstractTimeSampleProvider<HybridHRSpo2Sample> {
public HybridHRSpo2SampleProvider(GBDevice device, DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<HybridHRSpo2Sample, ?> getSampleDao() {
return getSession().getHybridHRSpo2SampleDao();
}
@NonNull
@Override
protected Property getTimestampSampleProperty() {
return HybridHRSpo2SampleDao.Properties.Timestamp;
}
@NonNull
@Override
protected Property getDeviceIdentifierSampleProperty() {
return HybridHRSpo2SampleDao.Properties.DeviceId;
}
@Override
public HybridHRSpo2Sample createSample() {
return new HybridHRSpo2Sample();
}
}

View File

@ -45,11 +45,13 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.Device;
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.Spo2Sample;
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.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
@ -103,6 +105,11 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
return new HybridHRActivitySampleProvider(device, session); return new HybridHRActivitySampleProvider(device, session);
} }
@Override
public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
return new HybridHRSpo2SampleProvider(device, session);
}
@Override @Override
public InstallHandler findInstallHandler(Uri uri, Context context) { public InstallHandler findInstallHandler(Uri uri, Context context) {
if (isHybridHR()) { if (isHybridHR()) {
@ -338,4 +345,9 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
public boolean supportsNavigation() { public boolean supportsNavigation() {
return isHybridHR(); return isHybridHR();
} }
@Override
public boolean supportsSpo2(GBDevice device) {
return device.getName().equals("Fossil Gen. 6 Hybrid");
}
} }

View File

@ -550,7 +550,7 @@ 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); ArrayList<ActivityEntry> entries = parser.parseFile(fileData).getKey();
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()];

View File

@ -102,8 +102,12 @@ import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.CommuteActionsActivi
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilFileReader; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilFileReader;
import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilHRInstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilHRInstallHandler;
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.NotificationHRConfiguration; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationHRConfiguration;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample;
import nodomain.freeyourgadget.gadgetbridge.externalevents.NotificationListener; import nodomain.freeyourgadget.gadgetbridge.externalevents.NotificationListener;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
@ -1232,19 +1236,28 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
@Override @Override
public void handleFileData(byte[] fileData) { public void handleFileData(byte[] fileData) {
try (DBHandler dbHandler = GBApplication.acquireDB()) { try (DBHandler dbHandler = GBApplication.acquireDB()) {
User user = DBHelper.getUser(dbHandler.getDaoSession());
Long userId = user.getId();
Device device = DBHelper.getDevice(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
Long deviceId = device.getId();
ActivityFileParser parser = new ActivityFileParser(); ActivityFileParser parser = new ActivityFileParser();
ArrayList<ActivityEntry> entries = parser.parseFile(fileData); Map.Entry<ArrayList<ActivityEntry>, ArrayList<HybridHRSpo2Sample>> parsedEntries = parser.parseFile(fileData);
// Activities
ArrayList<ActivityEntry> entries = parsedEntries.getKey();
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()];
Long userId = DBHelper.getUser(dbHandler.getDaoSession()).getId();
Long deviceId = DBHelper.getDevice(getDeviceSupport().getDevice(), dbHandler.getDaoSession()).getId();
for (int i = 0; i < entries.size(); i++) { for (int i = 0; i < entries.size(); i++) {
samples[i] = entries.get(i).toDAOActivitySample(userId, deviceId); samples[i] = entries.get(i).toDAOActivitySample(userId, deviceId);
} }
provider.addGBActivitySamples(samples); provider.addGBActivitySamples(samples);
// SpO2, should be empty for an unsupported device
ArrayList<HybridHRSpo2Sample> spo2Samples = parsedEntries.getValue();
HybridHRSpo2SampleProvider spo2Provider = new HybridHRSpo2SampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession());
for (HybridHRSpo2Sample sample : spo2Samples) {
sample.setDevice(device);
sample.setUser(user);
}
spo2Provider.addSamples(spo2Samples);
if (saveRawActivityFiles) { if (saveRawActivityFiles) {
writeFile(String.valueOf(System.currentTimeMillis()), fileData); writeFile(String.valueOf(System.currentTimeMillis()), fileData);

View File

@ -15,10 +15,12 @@
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 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;
public class ActivityFileParser { public class ActivityFileParser {
@ -28,10 +30,9 @@ public class ActivityFileParser {
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;
int spO2 = -1; // Should actually do something with this
public ArrayList<ActivityEntry> parseFile(byte[] file) { public Map.Entry<ArrayList<ActivityEntry>, ArrayList<HybridHRSpo2Sample>> parseFile(byte[] file) {
ByteBuffer buffer = ByteBuffer.wrap(file); ByteBuffer buffer = ByteBuffer.wrap(file);
buffer.order(ByteOrder.LITTLE_ENDIAN); buffer.order(ByteOrder.LITTLE_ENDIAN);
@ -48,6 +49,7 @@ 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<ActivityEntry> samples = new ArrayList<>();
ArrayList<HybridHRSpo2Sample> spo2Samples = new ArrayList<>();
finishCurrentPacket(samples); finishCurrentPacket(samples);
@ -97,7 +99,12 @@ public class ActivityFileParser {
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) {
buffer.get(new byte[4]); HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample();
spo2Sample.setTimestamp(currentTimestamp * 1000L);
spo2Sample.setSpo2(buffer.get() & 0xFF);
spo2Samples.add(spo2Sample);
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?
@ -163,9 +170,12 @@ public class ActivityFileParser {
break; break;
case (byte) 0xD6: // Seems to only come from intentional spot-checks, despite watch's value updating independently on occasion. case (byte) 0xD6:
spO2 = buffer.get() & 0xFF; buffer.get(); // Likely some statistic, notably different from 0xCE 0xD6
HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample();
spo2Sample.setTimestamp(currentTimestamp * 1000L);
spo2Sample.setSpo2(buffer.get() & 0xFF);
spo2Samples.add(spo2Sample);
break; break;
case (byte) 0xCB: // Very rare, may even be removed case (byte) 0xCB: // Very rare, may even be removed
@ -181,7 +191,7 @@ public class ActivityFileParser {
} }
return samples; return Map.entry(samples, spo2Samples);
} }
private static boolean elemValidFlags(byte value) { private static boolean elemValidFlags(byte value) {