From 4f30648886f2783ccf3b49a87b221a9691067210 Mon Sep 17 00:00:00 2001 From: Benjamin Swartley Date: Sat, 30 Nov 2024 15:45:34 -0500 Subject: [PATCH] Fossil/Skagen Gen. 6 Hybrids: Add SpO2 Support --- .../gadgetbridge/daogen/GBDaoGenerator.java | 10 +++- .../qhybrid/HybridHRSpo2SampleProvider.java | 56 +++++++++++++++++++ .../devices/qhybrid/QHybridCoordinator.java | 12 ++++ .../adapter/fossil/FossilWatchAdapter.java | 2 +- .../fossil_hr/FossilHRWatchAdapter.java | 25 +++++++-- .../qhybrid/parser/ActivityFileParser.java | 24 +++++--- 6 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index f5c81147b..8f18dfbb3 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -54,7 +54,7 @@ public class GBDaoGenerator { 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 user = addUserInfo(schema, userAttributes); @@ -115,6 +115,7 @@ public class GBDaoGenerator { addPineTimeActivitySample(schema, user, device); addWithingsSteelHRActivitySample(schema, user, device); addHybridHRActivitySample(schema, user, device); + addHybridHRSpo2Sample(schema, user, device); addVivomoveHrActivitySample(schema, user, device); addGarminFitFile(schema, user, device); addGarminActivitySample(schema, user, device); @@ -734,6 +735,13 @@ public class GBDaoGenerator { 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) { Entity cyclingSample = addEntity(schema, "CyclingSample"); addCommonTimeSampleProperties("AbstractTimeSample", cyclingSample, user, device); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java new file mode 100644 index 000000000..3bad94b2b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/HybridHRSpo2SampleProvider.java @@ -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 . */ +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 { + public HybridHRSpo2SampleProvider(GBDevice device, DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao 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(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java index cba18d037..35bc7288b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/qhybrid/QHybridCoordinator.java @@ -45,11 +45,13 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; @@ -103,6 +105,11 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator { return new HybridHRActivitySampleProvider(device, session); } + @Override + public TimeSampleProvider getSpo2SampleProvider(GBDevice device, DaoSession session) { + return new HybridHRSpo2SampleProvider(device, session); + } + @Override public InstallHandler findInstallHandler(Uri uri, Context context) { if (isHybridHR()) { @@ -338,4 +345,9 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator { public boolean supportsNavigation() { return isHybridHR(); } + + @Override + public boolean supportsSpo2(GBDevice device) { + return device.getName().equals("Fossil Gen. 6 Hybrid"); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil/FossilWatchAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil/FossilWatchAdapter.java index 91d5a8783..824d5bd55 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil/FossilWatchAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil/FossilWatchAdapter.java @@ -550,7 +550,7 @@ public class FossilWatchAdapter extends WatchAdapter { public void handleFileData(byte[] fileData) { try (DBHandler dbHandler = GBApplication.acquireDB()) { ActivityFileParser parser = new ActivityFileParser(); - ArrayList entries = parser.parseFile(fileData); + ArrayList entries = parser.parseFile(fileData).getKey(); HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession()); HybridHRActivitySample[] samples = new HybridHRActivitySample[entries.size()]; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java index 5a043de98..46125d7ca 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/adapter/fossil_hr/FossilHRWatchAdapter.java @@ -102,8 +102,12 @@ import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.CommuteActionsActivi import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilFileReader; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.FossilHRInstallHandler; 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.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample; import nodomain.freeyourgadget.gadgetbridge.externalevents.NotificationListener; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp; @@ -1232,19 +1236,28 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter { @Override public void handleFileData(byte[] fileData) { 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(); - ArrayList entries = parser.parseFile(fileData); + Map.Entry, ArrayList> parsedEntries = parser.parseFile(fileData); + // Activities + ArrayList entries = parsedEntries.getKey(); HybridHRActivitySampleProvider provider = new HybridHRActivitySampleProvider(getDeviceSupport().getDevice(), dbHandler.getDaoSession()); - 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++) { samples[i] = entries.get(i).toDAOActivitySample(userId, deviceId); } - provider.addGBActivitySamples(samples); + // SpO2, should be empty for an unsupported device + ArrayList 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) { writeFile(String.valueOf(System.currentTimeMillis()), fileData); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java index b79f4a01b..51356e76f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/qhybrid/parser/ActivityFileParser.java @@ -15,10 +15,12 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.parser; +import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; +import java.util.Map; public class ActivityFileParser { @@ -28,10 +30,9 @@ public class ActivityFileParser { int currentTimestamp = 0; // Aligns with `e2 04` from my testing ActivityEntry currentSample = null; int currentId = 1; - int spO2 = -1; // Should actually do something with this - public ArrayList parseFile(byte[] file) { + public Map.Entry, ArrayList> parseFile(byte[] file) { ByteBuffer buffer = ByteBuffer.wrap(file); 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 ArrayList samples = new ArrayList<>(); + ArrayList spo2Samples = new ArrayList<>(); finishCurrentPacket(samples); @@ -97,7 +99,12 @@ public class ActivityFileParser { continue; // Not sure what to do with this } 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) { if (buffer.get(buffer.position()) == (byte) 0xFE) { buffer.get(); } // WHY? @@ -163,9 +170,12 @@ public class ActivityFileParser { break; - case (byte) 0xD6: // Seems to only come from intentional spot-checks, despite watch's value updating independently on occasion. - spO2 = buffer.get() & 0xFF; - + case (byte) 0xD6: + 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; 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) {