From d74e2c53ee37e419581ca433ea3b84446b1844e0 Mon Sep 17 00:00:00 2001 From: d3vv3 Date: Thu, 12 Mar 2026 13:56:37 +0100 Subject: [PATCH] feat(withings,scanwatch): Initial support Withings Scanwatch Pairing Shortcut press-and-hold crown --- .../gadgetbridge/daogen/GBDaoGenerator.java | 23 +- .../activities/DevicesFragment.java | 2 +- .../DashboardBloodPressureWidget.java | 8 +- .../dashboard/DashboardPaiWidget.java | 8 +- .../schema/GadgetbridgeUpdate_129.java | 12 + .../devices/fitpro/FitProConstants.java | 12 +- .../WithingsScanwatchDeviceCoordinator.java | 152 ++++ .../WithingsScanwatchSampleProvider.java | 90 ++ .../WithingsScanwatchSettingsCustomizer.java | 76 ++ .../WithingsCalibrationActivity.java | 10 +- .../AbstractWithingsActivitySample.java | 33 + .../gadgetbridge/model/DeviceType.java | 2 + .../WithingsScanwatchDeviceSupport.java | 193 +++++ .../AuthenticationHandler.java | 4 +- .../WithingsBaseDeviceSupport.java | 817 ++++++++++++++++++ .../WithingsSteelHRDeviceSupport.java | 767 +--------------- .../activity/SleepActivitySampleHelper.java | 37 +- .../activity/WithingsActivityType.java | 8 +- .../communication/WithingsUUIDs.java | 54 ++ .../conversation/AbstractResponseHandler.java | 6 +- .../conversation/ActivitySampleHandler.java | 72 +- .../conversation/BatteryStateHandler.java | 4 +- .../conversation/ConversationQueue.java | 21 +- .../conversation/GetShortcutHandler.java | 69 ++ .../conversation/HeartRateHandler.java | 27 +- .../conversation/ScreenSettingsHandler.java | 55 ++ .../conversation/SetupFinishedHandler.java | 4 +- .../conversation/SyncFinishedHandler.java | 4 +- .../WorkoutScreenListHandler.java | 4 +- .../datastructures/DataStructureFactory.java | 3 + .../datastructures/ScreenSettings.java | 19 +- .../datastructures/ShortcutAction.java | 97 +++ .../datastructures/WithingsScreenId.java | 135 +++ .../datastructures/WithingsStructureType.java | 3 + .../message/GlyphRequestHandler.java | 6 +- .../communication/message/MessageBuilder.java | 6 +- .../communication/message/MessageFactory.java | 7 + .../message/WithingsMessageType.java | 5 + .../IncomingMessageHandlerFactory.java | 8 +- .../incoming/LiveHeartrateHandler.java | 6 +- .../message/incoming/LiveWorkoutHandler.java | 6 +- .../incoming/NotificationRequestHandler.java | 6 +- .../message/incoming/SyncRequestHandler.java | 6 +- .../notification/NotificationProvider.java | 8 +- app/src/main/res/values/arrays.xml | 80 ++ app/src/main/res/values/strings.xml | 32 + .../xml/devicesettings_withingsscanwatch.xml | 45 + .../AbstractDeviceCoordinatorTest.java | 1 + 48 files changed, 2167 insertions(+), 886 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchDeviceCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSettingsCustomizer.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWithingsActivitySample.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingsscanwatch/WithingsScanwatchDeviceSupport.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsBaseDeviceSupport.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsUUIDs.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/GetShortcutHandler.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ScreenSettingsHandler.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ShortcutAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsScreenId.java create mode 100644 app/src/main/res/xml/devicesettings_withingsscanwatch.xml diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index 9a0a0d2ad7..05b417c86a 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -146,6 +146,7 @@ public class GBDaoGenerator { addPineTimeActivitySample(schema, user, device); addPolarH10ActivitySample(schema, user, device); addWithingsSteelHRActivitySample(schema, user, device); + addWithingsScanwatchActivitySample(schema, user, device); addGenericBloodPressureSample(schema, user, device); addHybridHRActivitySample(schema, user, device); addHybridHRSpo2Sample(schema, user, device); @@ -1640,12 +1641,26 @@ public class GBDaoGenerator { private static Entity addWithingsSteelHRActivitySample(Schema schema, Entity user, Entity device) { Entity activitySample = addEntity(schema, "WithingsSteelHRActivitySample"); activitySample.implementsSerializable(); - addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device); - activitySample.addIntProperty("duration").notNull(); + addCommonActivitySampleProperties("AbstractWithingsActivitySample", activitySample, user, device); + activitySample.addIntProperty("duration").notNull().codeBeforeGetterAndSetter(OVERRIDE); activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE); activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE); - activitySample.addIntProperty("distance").notNull(); - activitySample.addIntProperty("calories").notNull(); + activitySample.addIntProperty("distance").notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty("calories").notNull().codeBeforeGetterAndSetter(OVERRIDE); + addHeartRateProperties(activitySample); + activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE); + return activitySample; + } + + private static Entity addWithingsScanwatchActivitySample(Schema schema, Entity user, Entity device) { + Entity activitySample = addEntity(schema, "WithingsScanwatchActivitySample"); + activitySample.implementsSerializable(); + addCommonActivitySampleProperties("AbstractWithingsActivitySample", activitySample, user, device); + activitySample.addIntProperty("duration").notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty("distance").notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty("calories").notNull().codeBeforeGetterAndSetter(OVERRIDE); addHeartRateProperties(activitySample); activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE); return activitySample; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java index 21bfab4e4e..924174be85 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DevicesFragment.java @@ -125,7 +125,7 @@ public class DevicesFragment extends Fragment { deviceListView.setAdapter(this.mGBDeviceAdapter); // get activity data asynchronously, this fills the deviceActivityHashMap - // and calls refreshPairedDevices() → notifyDataSetChanged + // and calls refreshPairedDevices() -> notifyDataSetChanged deviceListView.post(new Runnable() { @Override public void run() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java index 1669b077c6..f9cbf78b66 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardBloodPressureWidget.java @@ -98,16 +98,16 @@ public class DashboardBloodPressureWidget extends AbstractGaugeWidget { final int color; final float gaugeValue; if (systolic < 120 && diastolic < 80) { - color = android.graphics.Color.rgb(76, 175, 80); // green – Normal + color = android.graphics.Color.rgb(76, 175, 80); // green - Normal gaugeValue = 0.25f; } else if (systolic < 130 && diastolic < 80) { - color = android.graphics.Color.rgb(139, 195, 74); // lime – Elevated + color = android.graphics.Color.rgb(139, 195, 74); // lime - Elevated gaugeValue = 0.45f; } else if (systolic < 140 || diastolic < 90) { - color = android.graphics.Color.rgb(255, 152, 0); // orange – Stage 1 + color = android.graphics.Color.rgb(255, 152, 0); // orange - Stage 1 gaugeValue = 0.65f; } else { - color = android.graphics.Color.rgb(244, 67, 54); // red – Stage 2+ + color = android.graphics.Color.rgb(244, 67, 54); // red - Stage 2+ gaugeValue = 0.88f; } drawSimpleGauge(color, gaugeValue); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardPaiWidget.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardPaiWidget.java index e24e0c66a4..7f23425f1e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardPaiWidget.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/dashboard/DashboardPaiWidget.java @@ -40,7 +40,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.PaiSample; * * The gauge is rendered as a segmented arc mirroring the visual language used throughout * the rest of the dashboard. When the total PAI score meets or exceeds the device target, - * the arc is drawn in full using two proportional color segments — chart_pai_weekly for + * the arc is drawn in full using two proportional color segments - chart_pai_weekly for * the carry-over (total minus today) portion and chart_pai_today for today's contribution. * When the target has not yet been reached, the arc is only partially filled to reflect * the fraction of the goal completed. @@ -81,7 +81,7 @@ public class DashboardPaiWidget extends AbstractGaugeWidget { // Use getAllSamples bounded by timeFrom and timeTo to ensure we only show data // that actually exists within the selected day. Using getLatestSample(timeTo) has no lower - // bound and could bleed back to a previous day's sample — for example + // bound and could bleed back to a previous day's sample - for example // if Sunday has no data or the device was reset, it would incorrectly return Saturday's score. final long windowStartMs = dashboardData.timeFrom * 1000L; final long windowEndMs = dashboardData.timeTo * 1000L; @@ -97,7 +97,7 @@ public class DashboardPaiWidget extends AbstractGaugeWidget { coordinator.getPaiSampleProvider(dev, dbHandler.getDaoSession()); if (provider == null) { - LOG.warn("Device {} returned a null PAI sample provider — skipping", dev); + LOG.warn("Device {} returned a null PAI sample provider - skipping", dev); continue; } @@ -128,7 +128,7 @@ public class DashboardPaiWidget extends AbstractGaugeWidget { final PaiData paiData = (PaiData) dashboardData.get(DATA_KEY); if (paiData == null || paiData.target <= 0) { - // No data available — render an empty gauge rather than crashing. + // No data available - render an empty gauge rather than crashing. drawSimpleGauge(0, -1); setText("0"); return; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_129.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_129.java index 73cdfe15c9..232d74f94c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_129.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/schema/GadgetbridgeUpdate_129.java @@ -30,6 +30,18 @@ public class GadgetbridgeUpdate_129 implements DBUpdateScript { + HuaweiWorkoutSummarySampleDao.Properties.RawGpsFileLocation.columnName + "\" TEXT"; db.execSQL(statement); } + db.execSQL("CREATE TABLE IF NOT EXISTS \"WITHINGS_SCANWATCH_ACTIVITY_SAMPLE\" (" + + "\"TIMESTAMP\" INTEGER NOT NULL," + + "\"DEVICE_ID\" INTEGER NOT NULL," + + "\"USER_ID\" INTEGER NOT NULL," + + "\"DURATION\" INTEGER NOT NULL DEFAULT -1," + + "\"RAW_KIND\" INTEGER NOT NULL DEFAULT -1," + + "\"STEPS\" INTEGER NOT NULL DEFAULT -1," + + "\"DISTANCE\" INTEGER NOT NULL DEFAULT -1," + + "\"CALORIES\" INTEGER NOT NULL DEFAULT -1," + + "\"HEART_RATE\" INTEGER NOT NULL DEFAULT -1," + + "\"RAW_INTENSITY\" INTEGER NOT NULL DEFAULT -1," + + "PRIMARY KEY (\"TIMESTAMP\", \"DEVICE_ID\"));"); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java index 5f0713112a..e355c621fa 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/fitpro/FitProConstants.java @@ -183,8 +183,8 @@ public class FitProConstants { //00 01 00 96 03 08 16 7f //^^ zeros ^^ on/off ^^ sep ^^unknown ^^minutes ^^from ^^ to ^^ unknown - //minutes are array of minutes by 15, in 45,60, 75...→ 3,4,5... - //the byte 4 → 96 could be experimented with to set different values..., byte 3 is probably Hi, byte 4 Low + //minutes are array of minutes by 15, in 45,60, 75...-> 3,4,5... + //the byte 4 -> 96 could be experimented with to set different values..., byte 3 is probably Hi, byte 4 Low //public static final byte[] CMD_SET_LONG_SIT_REMINDER = new byte[]{(byte) 0x12, (byte) 0x5}; //maybe could be usign the ON/OFF? //public static final byte[] VALUE_SET_LONG_SIT_REMINDER_ON = new byte[]{0x0, 0x1, 0x0, (byte) 0x96}; @@ -194,7 +194,7 @@ public class FitProConstants { //Value: cd 00 0a 12 01 09 00 05 00 01 e0 05 28 OFF // 00 1 2 3 4 5 6 7 8 9 10 11 12 //byte 8 on/off - // 9,10,11,12 → time from/to + // 9,10,11,12 -> time from/to //public static final byte[] CMD_SET_DISPLAY_ON_LIFT = new byte[]{(byte) 0x12, (byte) 0x9}; //maybe could be usign the ON/OFF? //public static final byte VALUE_SET_DISPLAY_ON_LIFT_ON = 0x1; @@ -207,7 +207,7 @@ public class FitProConstants { // (byte) 0x12, (byte) 0x01, (byte) 0x0b, (byte) 0x00, (byte) 0x01, (byte) 0x01}; /*init procedure: - get pair cd 00 06 12 01 0a 00 01 02 → 18, 10 + get pair cd 00 06 12 01 0a 00 01 02 -> 18, 10 cd 00 09 12 01 01 00 04 55 f8 36 90 cd 00 05 1a 01 0a 00 00 cd 00 05 1a 01 0c 00 00 @@ -218,8 +218,8 @@ public class FitProConstants { cd 00 05 1a 01 0f 00 00 cd 00 05 1a 01 10 00 00 dc 00 05 1a 01 00 1c 01 - something cd 00 05 20 01 02 00 00 → 32, 2 - real time step cd 00 06 15 01 06 00 01 01 → 21, 6 + something cd 00 05 20 01 02 00 00 -> 32, 2 + real time step cd 00 06 15 01 06 00 01 01 -> 21, 6 */ //received heartrate diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchDeviceCoordinator.java new file mode 100644 index 0000000000..6b2dfe7a09 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchDeviceCoordinator.java @@ -0,0 +1,152 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.withingsscanwatch; + +import androidx.annotation.NonNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.WithingsScanwatchActivitySampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingsscanwatch.WithingsScanwatchDeviceSupport; + +public class WithingsScanwatchDeviceCoordinator extends AbstractBLEDeviceCoordinator { + + @Override + public Pattern getSupportedDeviceName() { + return Pattern.compile("(?i)^ScanWatch.*"); + } + + @Override + public Map, Property> getAllDeviceDao(@NonNull final DaoSession session) { + final Map, Property> map = new HashMap<>(1); + map.put(session.getWithingsScanwatchActivitySampleDao(), WithingsScanwatchActivitySampleDao.Properties.DeviceId); + return map; + } + + @Override + public int[] getSupportedDeviceSpecificSettings(GBDevice device) { + return new int[]{ R.xml.devicesettings_withingsscanwatch }; + } + + @Override + public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) { + return new WithingsScanwatchSettingsCustomizer(); + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_BOND; + } + + @Override + public boolean supportsRemSleep(@NonNull GBDevice device) { + return true; + } + + @Override + public boolean supportsDataFetching(@NonNull final GBDevice device) { + return true; + } + + @Override + public boolean supportsActivityTracking(@NonNull GBDevice device) { + return true; + } + + @Override + public boolean supportsRecordedActivities(final GBDevice device) { + return true; + } + + @Override + public SampleProvider getSampleProvider(GBDevice device, DaoSession session) { + return new WithingsScanwatchSampleProvider(device, session); + } + + @Override + public int getAlarmSlotCount(GBDevice gbDevice) { + return 3; + } + + @Override + public boolean supportsAlarmTitle(GBDevice device) { + return true; + } + + @Override + public boolean supportsAlarmDescription(GBDevice device) { + return true; + } + + @Override + public boolean supportsSmartWakeup(GBDevice device, int position) { + return true; + } + + @Override + public boolean supportsHeartRateMeasurement(GBDevice device) { + return true; + } + + @Override + public String getManufacturer() { + return "Withings"; + } + + @Override + public boolean supportsRealtimeData(@NonNull GBDevice device) { + return true; + } + + @Override + public String[] getSupportedLanguageSettings(GBDevice device) { + return new String[]{ "auto", "de_DE", "en_US", "es_ES", "fr_FR", "it_IT" }; + } + + @NonNull + @Override + public Class getDeviceSupportClass(final GBDevice device) { + return WithingsScanwatchDeviceSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_withings_scanwatch; + } + + @Override + public int getDefaultIconResource() { + return R.drawable.ic_device_watchxplus; + } + + @Override + public DeviceKind getDeviceKind(@NonNull GBDevice device) { + return DeviceKind.WATCH; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSampleProvider.java new file mode 100644 index 0000000000..5a3942acb0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSampleProvider.java @@ -0,0 +1,90 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.withingsscanwatch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.WithingsScanwatchActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.WithingsScanwatchActivitySampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; + +public class WithingsScanwatchSampleProvider extends AbstractSampleProvider { + public WithingsScanwatchSampleProvider(GBDevice device, DaoSession session) { + super(device, session); + } + + @Override + public AbstractDao getSampleDao() { + return getSession().getWithingsScanwatchActivitySampleDao(); + } + + @Nullable + @Override + protected Property getRawKindSampleProperty() { + return WithingsScanwatchActivitySampleDao.Properties.RawKind; + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return WithingsScanwatchActivitySampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return WithingsScanwatchActivitySampleDao.Properties.DeviceId; + } + + @Override + public ActivityKind normalizeType(int rawType) { + switch (rawType) { + case 1: return ActivityKind.LIGHT_SLEEP; + case 2: return ActivityKind.DEEP_SLEEP; + default: return ActivityKind.fromCode(rawType); + } + } + + @Override + public int toRawActivityKind(ActivityKind activityKind) { + switch (activityKind) { + case UNKNOWN: return 0; + case LIGHT_SLEEP: return 1; + case DEEP_SLEEP: return 2; + default: return activityKind.getCode(); + } + } + + @Override + public float normalizeIntensity(int rawIntensity) { + if (rawIntensity > 0) { + return (float) (Math.log(rawIntensity) / 8); + } + return 0; + } + + @Override + public WithingsScanwatchActivitySample createActivitySample() { + return new WithingsScanwatchActivitySample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSettingsCustomizer.java new file mode 100644 index 0000000000..33997d7659 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingsscanwatch/WithingsScanwatchSettingsCustomizer.java @@ -0,0 +1,76 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.withingsscanwatch; + +import android.os.Parcel; + +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import java.util.Collections; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettingsCustomizer { + + static final String PREF_SCREENS_SORTABLE = "withings_scanwatch_screens_sortable"; + static final String PREF_SHORTCUT_ACTION = "withings_scanwatch_shortcut_action"; + + @Override + public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) { + handler.addPreferenceHandlerFor(PREF_SCREENS_SORTABLE); + handler.addPreferenceHandlerFor(PREF_SHORTCUT_ACTION); + + final ListPreference shortcutPref = handler.findPreference(PREF_SHORTCUT_ACTION); + if (shortcutPref != null) { + shortcutPref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); + } + } + + @Override + public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) { + } + + @Override + public Set getPreferenceKeysWithSummary() { + return Collections.emptySet(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public WithingsScanwatchSettingsCustomizer createFromParcel(final Parcel in) { + return new WithingsScanwatchSettingsCustomizer(); + } + + @Override + public WithingsScanwatchSettingsCustomizer[] newArray(final int size) { + return new WithingsScanwatchSettingsCustomizer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java index 49030e7682..2bcd29a522 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/withingssteelhr/WithingsCalibrationActivity.java @@ -33,7 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; public class WithingsCalibrationActivity extends AbstractGBActivity { @@ -64,7 +64,7 @@ public class WithingsCalibrationActivity extends AbstractGBActivity { setContentView(R.layout.activity_withings_calibration); List devices = GBApplication.app().getDeviceManager().getSelectedDevices(); for(GBDevice device : devices){ - if(device.getType() == DeviceType.WITHINGS_STEEL_HR ){ + if(device.getType() == DeviceType.WITHINGS_STEEL_HR || device.getType() == DeviceType.WITHINGS_SCANWATCH){ this.device = device; break; } @@ -78,14 +78,14 @@ public class WithingsCalibrationActivity extends AbstractGBActivity { initView(); localBroadcastManager = LocalBroadcastManager.getInstance(this); - localBroadcastManager.sendBroadcast(new Intent(WithingsSteelHRDeviceSupport.START_HANDS_CALIBRATION_CMD)); + localBroadcastManager.sendBroadcast(new Intent(WithingsBaseDeviceSupport.START_HANDS_CALIBRATION_CMD)); } @Override protected void onDestroy() { super.onDestroy(); if (localBroadcastManager != null) { - localBroadcastManager.sendBroadcast(new Intent(WithingsSteelHRDeviceSupport.STOP_HANDS_CALIBRATION_CMD)); + localBroadcastManager.sendBroadcast(new Intent(WithingsBaseDeviceSupport.STOP_HANDS_CALIBRATION_CMD)); } } @@ -99,7 +99,7 @@ public class WithingsCalibrationActivity extends AbstractGBActivity { rotaryControl.setRotationListener(new RotaryControl.RotationListener() { @Override public void onRotation(short movementAmount) { - Intent calibration = new Intent(WithingsSteelHRDeviceSupport.HANDS_CALIBRATION_CMD); + Intent calibration = new Intent(WithingsBaseDeviceSupport.HANDS_CALIBRATION_CMD); calibration.putExtra("hand", hands[handIndex].code); calibration.putExtra("movementAmount", movementAmount); localBroadcastManager.sendBroadcast(calibration); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWithingsActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWithingsActivitySample.java new file mode 100644 index 0000000000..609e53ffa2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractWithingsActivitySample.java @@ -0,0 +1,33 @@ +/* Copyright (C) 2023-2024 Frank Ertl + + 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.entities; + +/** + * Common abstract base for Withings device activity samples (Steel HR and Scanwatch). + * Provides access to Withings-specific fields shared by all Withings devices: + * duration, distance, and calories. + */ +public abstract class AbstractWithingsActivitySample extends AbstractActivitySample { + public abstract int getDuration(); + public abstract void setDuration(int duration); + + public abstract int getDistance(); + public abstract void setDistance(int distance); + + public abstract int getCalories(); + public abstract void setCalories(int calories); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index f1f7fc1fb0..2249de5a4c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -422,6 +422,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.vibratissimo.VibratissimoCoo import nodomain.freeyourgadget.gadgetbridge.devices.waspos.WaspOSCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.watch9.Watch9DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsSteelHRDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.withingsscanwatch.WithingsScanwatchDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watches.MiBand10Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watches.MiBand4CCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watches.MiBand7ProCoordinator; @@ -859,6 +860,7 @@ public enum DeviceType { REALME_BUDS_AIR_5_PRO(RealmeBudsAir5ProCoordinator.class), SOFLOW_SO6(SoFlowCoordinator.class), WITHINGS_STEEL_HR(WithingsSteelHRDeviceCoordinator.class), + WITHINGS_SCANWATCH(WithingsScanwatchDeviceCoordinator.class), SONY_WENA_3(SonyWena3Coordinator.class), FEMOMETER_VINCA2(FemometerVinca2DeviceCoordinator.class), PIXOO(PixooCoordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingsscanwatch/WithingsScanwatchDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingsscanwatch/WithingsScanwatchDeviceSupport.java new file mode 100644 index 0000000000..7eb9327da9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingsscanwatch/WithingsScanwatchDeviceSupport.java @@ -0,0 +1,193 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.service.devices.withingsscanwatch; + +import android.content.SharedPreferences; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.withingsscanwatch.WithingsScanwatchSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.WithingsUUIDs; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetShortcutHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.EndOfTransmission; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ShortcutAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsScreenId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; + +/** + * Device support for Withings Scanwatch. The Scanwatch uses the same BLE protocol as the + * Steel HR but with a different service UUID suffix (005d vs 0037). + * All protocol logic is in {@link WithingsBaseDeviceSupport}. + */ +public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport { + + private static final Logger logger = LoggerFactory.getLogger(WithingsScanwatchDeviceSupport.class); + + static final String PREF_SCREENS_SORTABLE = "withings_scanwatch_screens_sortable"; + private static final String PREF_SCREENS_LAST_SENT = "withings_scanwatch_screens_last_sent"; + + @Override + protected WithingsUUIDs getWithingsUUIDs() { + return WithingsUUIDs.SCANWATCH; + } + + @Override + public AbstractSampleProvider createSampleProvider(GBDevice device, DaoSession session) { + return new WithingsScanwatchSampleProvider(device, session); + } + + private static final String PREF_SHORTCUT_ACTION = "withings_scanwatch_shortcut_action"; + + @Override + protected void addExtraSyncCommands() { + addSimpleConversationToQueue( + new WithingsMessage(WithingsMessageType.GET_SHORTCUT), + new GetShortcutHandler(this) + ); + } + + @Override + protected boolean handleExtraConfiguration(String config) { + if (PREF_SCREENS_SORTABLE.equals(config)) { + clearQueue(); + addScreenListCommands(); + sendQueue(); + return true; + } + if (PREF_SHORTCUT_ACTION.equals(config)) { + final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()); + final String val = prefs.getString(PREF_SHORTCUT_ACTION, "0"); + final byte action; + try { + action = Byte.parseByte(val); + } catch (NumberFormatException e) { + logger.warn("Invalid shortcut action value: {}", val); + return true; + } + final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_SHORTCUT); + msg.addDataStructure(new ShortcutAction(action)); + clearQueue(); + addSimpleConversationToQueue(msg); + sendQueue(); + return true; + } + return false; + } + + /** + * Builds the Scanwatch screen list from the user's drag-sort preference. + * + *

The preference {@code withings_scanwatch_screens_sortable} stores a comma-separated list + * of screen value keys in the order the user has arranged them. Only screens present in the + * list are enabled; removing an entry from the list disables that screen on the watch. + * + *

Each screen has a fixed internal slot number (confirmed from {@code reorder_screens.zip} + * BLE capture). The watch determines display order by ascending slot number, so the slot values + * are fixed per screen - reordering is achieved by which screens are included, not by changing + * slot numbers. + * + *

Screen ID <-> slot mapping is defined in {@link WithingsScreenId#getScanwatchSlot(int)}. + * + *

This method is a no-op if the current screen list matches what was last successfully sent + * to the watch. + */ + @Override + protected void addScreenListCommands() { + final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()); + + final List defaultScreens = Arrays.asList( + getContext().getResources().getStringArray(R.array.pref_withings_scanwatch_screens_default)); + + final String screensPref = prefs.getString(PREF_SCREENS_SORTABLE, null); + final List enabledScreens; + if (screensPref == null || screensPref.isEmpty()) { + enabledScreens = defaultScreens; + } else { + enabledScreens = Arrays.asList(screensPref.split(",")); + } + + // Normalise to a canonical comma-separated string for comparison + final String currentValue = String.join(",", enabledScreens); + final String lastSentValue = prefs.getString(PREF_SCREENS_LAST_SENT, null); + + if (Objects.equals(currentValue, lastSentValue)) { + logger.debug("Screen list unchanged ({}), skipping SET_SCREEN_LIST", currentValue); + return; + } + + Message message = new WithingsMessage(WithingsMessageType.SET_SCREEN_LIST); + for (final String screenKey : enabledScreens) { + final int screenId = screenKeyToId(screenKey); + if (screenId < 0) { + logger.warn("Unknown screen key '{}', skipping", screenKey); + continue; + } + final byte slot = WithingsScreenId.getScanwatchSlot(screenId); + if (slot < 0) { + logger.warn("No fixed slot for screen key '{}' (id=0x{:04x}), skipping", screenKey, screenId); + continue; + } + message.addDataStructure(buildScreen(screenId, slot)); + } + message.addDataStructure(new EndOfTransmission()); + addSimpleConversationToQueue(message); + + // Record what we sent so we can skip on future syncs if nothing changed + prefs.edit().putString(PREF_SCREENS_LAST_SENT, currentValue).apply(); + } + + /** + * Maps a screen preference value key (as stored in the DragSortListPreference) to the + * corresponding {@link WithingsScreenId} constant. + * + * @param key the string value from {@code pref_withings_scanwatch_screens_values} + * @return the screen ID constant, or {@code -1} if the key is not recognised + */ + private static int screenKeyToId(final String key) { + switch (key) { + case "date": return WithingsScreenId.DATE; + case "sleep": return WithingsScreenId.SLEEP; + case "ecg": return WithingsScreenId.ECG; + case "elevation": return WithingsScreenId.ELEVATION; + case "heart_rate": return WithingsScreenId.HEART_RATE; + case "spo2": return WithingsScreenId.SPO2; + case "calories": return WithingsScreenId.CALORIES; + case "settings": return WithingsScreenId.SETTINGS; + case "workouts": return WithingsScreenId.WORKOUTS; + case "distance": return WithingsScreenId.DISTANCE; + case "steps": return WithingsScreenId.STEPS; + case "breathe": return WithingsScreenId.BREATHE; + case "clock": return WithingsScreenId.CLOCK; + default: return -1; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/AuthenticationHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/AuthenticationHandler.java index d290d8fef0..d74b99f8a8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/AuthenticationHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/AuthenticationHandler.java @@ -46,10 +46,10 @@ public class AuthenticationHandler extends AbstractResponseHandler { // TODO: Save this somewhere if we actually decide to use te secret for more security: private final String secret = "2EM5zNP37QzM00hmP6BFTD92nG15XwNd"; - private WithingsSteelHRDeviceSupport support; + private WithingsBaseDeviceSupport support; private Challenge challengeToSend; - public AuthenticationHandler(WithingsSteelHRDeviceSupport support) { + public AuthenticationHandler(WithingsBaseDeviceSupport support) { super(support); this.support = support; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsBaseDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsBaseDeviceSupport.java new file mode 100644 index 0000000000..207e7bc14c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsBaseDeviceSupport.java @@ -0,0 +1,817 @@ +/* Copyright (C) 2023-2024 Ascense, Frank Ertl + + 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.service.devices.withingssteelhr; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; +import nodomain.freeyourgadget.gadgetbridge.model.Alarm; +import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.ServerTransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.WithingsActivityType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.WithingsUUIDs; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ActivitySampleHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.BatteryStateHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.Conversation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ConversationQueue; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.HeartRateHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ResponseHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SetupFinishedHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SimpleConversation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SyncFinishedHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ScreenSettingsHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.WorkoutScreenListHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivityTarget; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmName; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmSettings; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmStatus; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AncsStatus; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.DataStructureFactory; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.EndOfTransmission; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.GetActivitySamples; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageMetaData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.Locale; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.MoveHand; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.Probe; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ProbeOsVersion; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsScreenId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.Time; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.TypeVersion; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.User; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.UserUnit; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.UserUnitConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WorkoutScreen; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.ExpectedResponse; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.MessageBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.MessageFactory; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.SimpleHexToByteMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.IncomingMessageHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.IncomingMessageHandlerFactory; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.LiveWorkoutHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.GetNotificationAttributes; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.GetNotificationAttributesResponse; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.NotificationProvider; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.NotificationSource; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO; + +public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDeviceSupport { + + private static final Logger logger = LoggerFactory.getLogger(WithingsBaseDeviceSupport.class); + public static final String LAST_ACTIVITY_SYNC = "lastActivitySync"; + public static final String HANDS_CALIBRATION_CMD = "withings_hands_calibration"; + public static final String START_HANDS_CALIBRATION_CMD = "start_withings_hands_calibration"; + public static final String STOP_HANDS_CALIBRATION_CMD = "stop_withings_hands_calibration"; + private final MessageBuilder messageBuilder; + private LiveWorkoutHandler liveWorkoutHandler; + private ActivitySampleHandler activitySampleHandler; + private final ConversationQueue conversationQueue; + private boolean firstTimeConnect; + private BluetoothGattCharacteristic notificationSourceCharacteristic; + private BluetoothGattCharacteristic dataSourceCharacteristic; + private BluetoothDevice device; + + /** + * Creates a device-specific sample provider for storing activity data. + * Subclasses return the provider appropriate for their database table. + */ + public abstract nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider createSampleProvider(nodomain.freeyourgadget.gadgetbridge.impl.GBDevice device, nodomain.freeyourgadget.gadgetbridge.entities.DaoSession session); + + /** + * Returns the BLE UUID set for this device variant. + * Steel HR uses suffix "0037"; Scanwatch uses suffix "005d". + */ + protected abstract WithingsUUIDs getWithingsUUIDs(); + private boolean syncInProgress; + private final ActivityUser activityUser; + private final NotificationProvider notificationProvider; + private final IncomingMessageHandlerFactory incomingMessageHandlerFactory; + private final Handler backgroundTasksHandler = new Handler(Looper.getMainLooper()); + + public WithingsBaseDeviceSupport() { + super(logger); + conversationQueue = new ConversationQueue(this); + notificationProvider = NotificationProvider.getInstance(this); + messageBuilder = new MessageBuilder(this, new MessageFactory(new DataStructureFactory())); + liveWorkoutHandler = new LiveWorkoutHandler(this); + incomingMessageHandlerFactory = IncomingMessageHandlerFactory.getInstance(this); + addSupportedService(getWithingsUUIDs().WITHINGS_SERVICE_UUID); + addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS); + addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE); + addANCSService(); + activityUser = new ActivityUser(); + + IntentFilter commandFilter = new IntentFilter(HANDS_CALIBRATION_CMD); + commandFilter.addAction(START_HANDS_CALIBRATION_CMD); + commandFilter.addAction(STOP_HANDS_CALIBRATION_CMD); + final BroadcastReceiver commandReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction() == null) { + return; + } + + switch (intent.getAction()) { + case HANDS_CALIBRATION_CMD: + MoveHand moveHand = new MoveHand(); + moveHand.setHand(intent.getShortExtra("hand", (short) 1)); + moveHand.setMovement(intent.getShortExtra("movementAmount", (short) 1)); + sendToDevice(new WithingsMessage(WithingsMessageType.MOVE_HAND, moveHand)); + break; + case START_HANDS_CALIBRATION_CMD: + sendToDevice(new WithingsMessage(WithingsMessageType.START_HANDS_CALIBRATION)); + break; + case STOP_HANDS_CALIBRATION_CMD: + sendToDevice(new WithingsMessage(WithingsMessageType.STOP_HANDS_CALIBRATION)); + break; + } + } + }; + + LocalBroadcastManager.getInstance(GBApplication.getContext()).registerReceiver(commandReceiver, commandFilter); + } + + @Override + public void dispose() { + synchronized (ConnectionMonitor) { + backgroundTasksHandler.removeCallbacksAndMessages(null); + + super.dispose(); + } + } + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + logger.debug("Starting initialization..."); + conversationQueue.clear(); + builder.setDeviceState(GBDevice.State.INITIALIZING); + getDevice().setFirmwareVersion("N/A"); + getDevice().setFirmwareVersion2("N/A"); + + // Delay initialization with 2 seconds to give the watch time to settle + backgroundTasksHandler.removeCallbacksAndMessages(null); + backgroundTasksHandler.postDelayed(this::postConnectInitialization, 2000); + + return builder; + } + + private void postConnectInitialization() { + final TransactionBuilder builder = createTransactionBuilder("delayed initialization"); + builder.notify(getWithingsUUIDs().WITHINGS_WRITE_CHARACTERISTIC_UUID, true); + builder.requestMtu(512); + builder.queue(); + } + + @Override + public boolean connectFirstTime() { + firstTimeConnect = true; + return connect(); + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + super.onMtuChanged(gatt, mtu, status); + if (status != BluetoothGatt.GATT_SUCCESS) { + logger.error("Failed to change mtu - disconnecting"); + disconnect(); + return; + } + + logger.debug("MTU has changed to {}", mtu); + if (firstTimeConnect) { + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.INITIAL_CONNECT)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_LOCALE, getLocale())); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.START_HANDS_CALIBRATION)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.STOP_HANDS_CALIBRATION)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time())); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.DISTANCE, getUnit()))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.CLOCK_MODE, getTimeMode()))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(activityUser.getStepsGoal()))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ANCS_STATUS, new AncsStatus(true))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SETUP_FINISHED), new SetupFinishedHandler(this)); + } else { + Message message = new WithingsMessage(WithingsMessageType.PROBE); + message.addDataStructure(new Probe((short) 1, (short) 1, 5100401)); + message.addDataStructure(new ProbeOsVersion((short) Build.VERSION.SDK_INT)); + conversationQueue.clear(); + addSimpleConversationToQueue(message, new AuthenticationHandler(this)); + } + + if (!firstTimeConnect) { + finishInitialization(); + } + conversationQueue.send(); + } + + public void doSync() { + activitySampleHandler = new ActivitySampleHandler(this); + conversationQueue.clear(); + try { + if (syncInProgress) { + return; + } + + getDevice().setBusyTask(R.string.busy_task_syncing, getContext()); + syncInProgress = true; + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.INITIAL_CONNECT)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time())); + + if (shoudSync()) { + logger.debug("Doing full sync..."); + WithingsMessage message = new WithingsMessage(WithingsMessageType.SET_USER); + message.addDataStructure(getUser()); + // The UserSecret appears in the original communication with the HealthMate app. Until now GB works without the secret. + // This makes the "authentication" far easier. However if it turns out that this is needed, we would need to find a way to savely store a unique generated secret. + // message.addDataStructure(new UserSecret()); + addSimpleConversationToQueue(message); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(activityUser.getStepsGoal()))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.DISTANCE, getUnit()))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.CLOCK_MODE, getTimeMode()))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM_SETTINGS)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_SCREEN_SETTINGS), new ScreenSettingsHandler(this)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM_ENABLED)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_WORKOUT_SCREEN_LIST), new WorkoutScreenListHandler(this)); + addExtraSyncCommands(); + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(getLastSyncTimestamp()); + message = new WithingsMessage(WithingsMessageType.GET_ACTIVITY_SAMPLES, ExpectedResponse.EOT); + message.addDataStructure(new GetActivitySamples(c.getTimeInMillis() / 1000, (short) 0)); + addSimpleConversationToQueue(message, activitySampleHandler); + message = new WithingsMessage(WithingsMessageType.GET_MOVEMENT_SAMPLES, ExpectedResponse.EOT); + message.addDataStructure(new GetActivitySamples(c.getTimeInMillis() / 1000, (short) 0)); + message.addDataStructure(new TypeVersion()); + addSimpleConversationToQueue(message, activitySampleHandler); + message = new WithingsMessage(WithingsMessageType.GET_HEARTRATE_SAMPLES, ExpectedResponse.EOT); + message.addDataStructure(new GetActivitySamples(c.getTimeInMillis() / 1000, (short) 0)); + message.addDataStructure(new TypeVersion()); + addSimpleConversationToQueue(message, activitySampleHandler); + } + } catch (Exception e) { + logger.error("Could not synchronize! ", e); + conversationQueue.clear(); + } finally { + // This must be done in all cases or the watch won't respond anymore! + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SYNC_OK), new SyncFinishedHandler(this)); + } + conversationQueue.send(); + } + + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, + byte[] data) { + if (super.onCharacteristicChanged(gatt, characteristic, data)) { + return true; + } + + logger.debug("onCharacteristicChanged raw: {}", nodomain.freeyourgadget.gadgetbridge.util.GB.hexdump(data)); + boolean complete = messageBuilder.buildMessage(data); + if (complete) { + Message message = messageBuilder.getMessage(); + if (message.isIncomingMessage()) { + logger.debug("received incoming message: {}", message.getType()); + IncomingMessageHandler handler = incomingMessageHandlerFactory.getHandler(message); + if (handler == null) { + logger.warn("No handler for incoming message type={}", message.getType()); + } else { + handler.handleMessage(message); + } + } else { + logger.debug("received response message: type={} (0x{})", message.getType(), Integer.toHexString(message.getType() & 0xffff)); + conversationQueue.processResponse(message); + } + } + + return true; + } + + @Override + public void onSetCallState(CallSpec callSpec) { + if (callSpec.command == CallSpec.CALL_INCOMING) { + NotificationSpec notificationSpec = new NotificationSpec(); + notificationSpec.sourceAppId = "incoming.call"; + notificationSpec.title = callSpec.number; + notificationSpec.sender = callSpec.name; + notificationSpec.type = NotificationType.GENERIC_PHONE; + notificationProvider.notifyClient(notificationSpec); + } else { + logger.info("Received yet unhandled call command: " + callSpec.command); + } + } + + @Override + public void onNotification(NotificationSpec notificationSpec) { + notificationProvider.notifyClient(notificationSpec); + } + + @Override + public void onSetAlarms(ArrayList alarms) { + if (alarms.size() > 3) { + throw new IllegalArgumentException("This device only has three alarm slots!"); + } + + if (alarms.size() == 0) { + return; + } + + boolean noAlarmsEnabled = true; + conversationQueue.clear(); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM)); + for (Alarm alarm : alarms) { + if (alarm.getEnabled() && !alarm.getUnused()) { + noAlarmsEnabled = false; + addAlarm(alarm); + } + } + + if (noAlarmsEnabled) { + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(false))); + } + + conversationQueue.send(); + } + + @Override + public boolean onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { + this.device = device; + return true; + } + + @Override + public boolean onCharacteristicWriteRequest(BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { + if (characteristic.getUuid().equals(getWithingsUUIDs().CONTROL_POINT_CHARACTERISTIC_UUID)) { + logger.debug("Got GetNotificationAttributesRequest: " + GB.hexdump(value)); + GetNotificationAttributes request = new GetNotificationAttributes(); + request.deserialize(value); + notificationProvider.handleNotificationAttributeRequest(request); + } + + return true; + } + + @Override + public void onFetchRecordedData(int dataTypes) { + doSync(); + } + + @Override + public void onHeartRateTest() { + conversationQueue.clear(); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_HR), new HeartRateHandler(this)); + conversationQueue.send(); + } + + @Override + public void onSendConfiguration(String config) { + try { + switch (config) { + case HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE: + setWorkoutActivityTypes(); + break; + case PREF_LANGUAGE: + setLanguage(); + break; + default: + if (!handleExtraConfiguration(config)) { + logger.debug("unknown configuration setting received: " + config); + } + } + } catch (Exception e) { + GB.toast("Error setting configuration", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + /** + * Called from {@link #onSendConfiguration(String)} for device-specific configuration keys + * not handled by the base class. + * + * @param config the configuration key + * @return {@code true} if the config was handled, {@code false} otherwise + */ + protected boolean handleExtraConfiguration(String config) { + return false; + } + + @Override + public void onTestNewFunction() { + String hexMessage = "0105080015050900111006040102030507000000000000000000"; + conversationQueue.clear(); + addSimpleConversationToQueue(new SimpleHexToByteMessage(hexMessage)); + conversationQueue.send(); + } + + @Override + public boolean useAutoConnect() { + return false; + } + + public void sendToDevice(Message message) { + if (message == null) { + return; + } + + try { + TransactionBuilder builder = createTransactionBuilder("conversation"); + builder.setCallback(this); + BluetoothGattCharacteristic characteristic = getCharacteristic(getWithingsUUIDs().WITHINGS_WRITE_CHARACTERISTIC_UUID); + if (characteristic == null) { + logger.info("Characteristic with UUID " + getWithingsUUIDs().WITHINGS_WRITE_CHARACTERISTIC_UUID + " not found."); + return; + } + + byte[] rawData = message.getRawData(); + builder.writeChunkedData(characteristic, rawData, getMTU() - 3); + builder.queue(); + } catch (Exception e) { + logger.warn("Could not send message because of " + e.getMessage()); + } + } + + public void sendAncsNotificationSourceNotification(NotificationSource notificationSource) { + try { + ServerTransactionBuilder builder = performServer("notificationSourceNotification"); + byte[] data = notificationSource.serialize(); + builder.notifyCharacteristicChanged(device, notificationSourceCharacteristic, data); + builder.queue(getQueue()); + } catch (IOException e) { + logger.error("Could not send notification.", e); + GB.toast("Could not send notification.", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + public void sendAncsDataSourceNotification(GetNotificationAttributesResponse response) { + try { + ServerTransactionBuilder builder = performServer("dataSourceNotification"); + byte[] data = response.serialize(); + builder.notifyCharacteristicChanged(device, dataSourceCharacteristic, data); + builder.queue(getQueue()); + } catch (IOException e) { + logger.error("Could not send notification.", e); + GB.toast("Could not send notification.", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + public void finishInitialization() { + TransactionBuilder builder = createTransactionBuilder("setupFinished"); + builder.setDeviceState(GBDevice.State.INITIALIZED); + builder.queue(); + logger.debug("Finished initialization."); + } + + public void finishSync() { + syncInProgress = false; + if (getDevice().isBusy()) { + getDevice().unsetBusyTask(); + getDevice().sendDeviceUpdateIntent(getContext()); + } + activitySampleHandler.onSyncFinished(); + saveLastSyncTimestamp(new Date().getTime()); + } + + void onAuthenticationFinished() { + if (!firstTimeConnect) { + doSync(); + } else { + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ANCS_STATUS, new AncsStatus(true))); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS)); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this)); + conversationQueue.send(); + } + } + + private void addAlarm(Alarm alarm) { + AlarmSettings alarmSettings = new AlarmSettings(); + alarmSettings.setHour((short) alarm.getHour()); + alarmSettings.setMinute((short) alarm.getMinute()); + alarmSettings.setDayOfWeek(mapRepetitionToWithingsValue(alarm)); + if (alarm.getSmartWakeup()) { + // Healthmate has the possibility to change the minutecount, in GB we use a fixed value of 15 + alarmSettings.setSmartWakeupMinutes((short) 15); + } + + Message alarmMessage = new WithingsMessage(WithingsMessageType.SET_ALARM, alarmSettings); + if (!StringUtils.isEmpty(alarm.getTitle())) { + AlarmName alarmName = new AlarmName(alarm.getTitle()); + alarmMessage.addDataStructure(alarmName); + } + + addSimpleConversationToQueue(alarmMessage); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(true))); + } + + private short mapRepetitionToWithingsValue(Alarm alarm) { + int repetition = 0; + if (alarm.getRepetition(Alarm.ALARM_MON)) { + repetition += 0x02; + } + if (alarm.getRepetition(Alarm.ALARM_TUE)) { + repetition += 0x04; + } + if (alarm.getRepetition(Alarm.ALARM_WED)) { + repetition += 0x08; + } + if (alarm.getRepetition(Alarm.ALARM_THU)) { + repetition += 0x10; + } + if (alarm.getRepetition(Alarm.ALARM_FRI)) { + repetition += 0x20; + } + if (alarm.getRepetition(Alarm.ALARM_SAT)) { + repetition += 0x40; + } + if (alarm.getRepetition(Alarm.ALARM_SUN)) { + repetition += 0x01; + } + + return (short)(repetition + 0x80); + } + + private void addANCSService() { + BluetoothGattService withingsGATTService = new BluetoothGattService(getWithingsUUIDs().WITHINGS_ANCS_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY); + notificationSourceCharacteristic = new BluetoothGattCharacteristic(getWithingsUUIDs().NOTIFICATION_SOURCE_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ); + notificationSourceCharacteristic.addDescriptor(new BluetoothGattDescriptor(getWithingsUUIDs().CCC_DESCRIPTOR_UUID, BluetoothGattCharacteristic.PERMISSION_WRITE)); + withingsGATTService.addCharacteristic(notificationSourceCharacteristic); + withingsGATTService.addCharacteristic(new BluetoothGattCharacteristic(getWithingsUUIDs().CONTROL_POINT_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_WRITE, BluetoothGattCharacteristic.PERMISSION_WRITE)); + dataSourceCharacteristic = new BluetoothGattCharacteristic(getWithingsUUIDs().DATA_SOURCE_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ); + dataSourceCharacteristic.addDescriptor(new BluetoothGattDescriptor(getWithingsUUIDs().CCC_DESCRIPTOR_UUID, BluetoothGattCharacteristic.PERMISSION_WRITE)); + withingsGATTService.addCharacteristic(dataSourceCharacteristic); + addSupportedServerService(withingsGATTService); + } + + protected void addSimpleConversationToQueue(Message message) { + addSimpleConversationToQueue(message, null); + } + + protected void addSimpleConversationToQueue(Message message, ResponseHandler handler) { + Conversation conversation = new SimpleConversation(handler); + conversation.setRequest(message); + conversationQueue.addConversation(conversation); + } + + /** Clears any pending conversations from the queue. */ + protected void clearQueue() { + conversationQueue.clear(); + } + + /** Starts sending the queued conversations to the device. */ + protected void sendQueue() { + conversationQueue.send(); + } + + private void saveLastSyncTimestamp(@NonNull long timestamp) { + SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()).edit(); + editor.putLong(LAST_ACTIVITY_SYNC, timestamp); + editor.apply(); + } + + private long getLastSyncTimestamp() { + SharedPreferences settings = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); + long lastSyncTime = settings.getLong(LAST_ACTIVITY_SYNC, 0); + if (lastSyncTime > 0) { + return lastSyncTime; + } else { + Date currentDate = new Date(); + Calendar c = Calendar.getInstance(); + c.setTimeInMillis(currentDate.getTime()); + c.add(Calendar.HOUR, - 10); + return c.getTimeInMillis(); + } + } + + private boolean shoudSync() { + long lastSynced = getLastSyncTimestamp(); + int minuteInMillis = 60 * 1000; + return new Date().getTime() - lastSynced > minuteInMillis; + } + + private User getUser() { + User user = new User(); + ActivityUser activityUser = new ActivityUser(); + user.setName(activityUser.getName()); + user.setGender((byte) activityUser.getGender()); + user.setHeight(activityUser.getHeightCm()); + user.setWeight(activityUser.getWeightKg()); + user.setBirthdate(activityUser.getUserBirthday()); + return user; + } + + protected void addScreenListCommands() { + // Default screen list used by the Withings Steel HR. + // Subclasses (e.g. WithingsScanwatchDeviceSupport) should override this + // to provide a device-specific or user-configurable screen list. + // Screen IDs are defined in WithingsScreenId; idOnDevice is the slot/position on the watch. + // TODO: these IDs need to be verified via BLE packet capture (HCI snoop log). + Message message = new WithingsMessage(WithingsMessageType.SET_SCREEN_LIST); + message.addDataStructure(buildScreen(WithingsScreenId.NOTIFICATIONS, (byte) 6)); + message.addDataStructure(buildScreen(WithingsScreenId.HEART_RATE_STEEL,(byte) 1)); + message.addDataStructure(buildScreen(WithingsScreenId.ACTIVITY, (byte) 4)); + message.addDataStructure(buildScreen(WithingsScreenId.CALORIES_STEEL, (byte) 2)); + message.addDataStructure(buildScreen(WithingsScreenId.ALARM, (byte) 3)); + message.addDataStructure(buildScreen(WithingsScreenId.CONFIG, (byte) 7)); + message.addDataStructure(buildScreen(WithingsScreenId.CHRONOMETER, (byte) 9)); + message.addDataStructure(new EndOfTransmission()); + addSimpleConversationToQueue(message); + } + + /** + * Convenience method: creates a {@link ScreenSettings} with the given screen ID and slot. + */ + protected ScreenSettings buildScreen(int screenId, byte slotOnDevice) { + ScreenSettings settings = new ScreenSettings(); + settings.setId(screenId); + settings.setIdOnDevice(slotOnDevice); + return settings; + } + + /** + * Hook for subclasses to add device-specific commands to the sync queue. + * Called within the {@code shoudSync()} block of {@link #doSync()}, after the common + * commands (user, alarms, screen settings, workout screens) have been queued. + * + *

The default implementation is a no-op. + */ + protected void addExtraSyncCommands() { + // no-op by default + } + + private void setWorkoutActivityTypes() { + final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()); + + final List allActivityTypes = Arrays.asList(getContext().getResources().getStringArray(R.array.pref_withings_steel_activity_types_values)); + final List defaultActivityTypes = Arrays.asList(getContext().getResources().getStringArray(R.array.pref_withings_steel_activity_types_default)); + final String activityTypesPref = prefs.getString("workout_activity_types_sortable", null); + + final List enabledActivityTypes; + if (activityTypesPref == null || activityTypesPref.equals("")) { + enabledActivityTypes = defaultActivityTypes; + } else { + enabledActivityTypes = Arrays.asList(activityTypesPref.split(",")); + } + + conversationQueue.clear(); + for (int i = 0; i < enabledActivityTypes.size(); i++) { + String workoutType = enabledActivityTypes.get(i); + try { + Message message = createWorkoutScreenMessage(workoutType); + if (i == enabledActivityTypes.size() - 1) { + message.addDataStructure(new EndOfTransmission()); + } + addSimpleConversationToQueue(message); + } catch (Exception e) { + logger.warn("exception in setWorkoutActivityTypes", e); + } + } + + conversationQueue.send(); + } + + @NonNull + private Message createWorkoutScreenMessage(String workoutType) { + WithingsActivityType withingsActivityType = WithingsActivityType.fromPrefValue(workoutType); + int code = withingsActivityType.getCode(); + Message message = new WithingsMessage(WithingsMessageType.SET_WORKOUT_SCREEN, ExpectedResponse.NONE); + WorkoutScreen workoutScreen = new WorkoutScreen(); + workoutScreen.setId(code); + final int stringId = getContext().getResources().getIdentifier("activity_type_" + workoutType, "string", getContext().getPackageName()); + workoutScreen.setName(getContext().getString(stringId)); + message.addDataStructure(workoutScreen); + + ImageMetaData imageMetaData = new ImageMetaData(); + imageMetaData.setHeight((byte)24); + imageMetaData.setWidth((byte)22); + message.addDataStructure(imageMetaData); + + ImageData imageData = new ImageData(); + final int drawableId = withingsActivityType.toActivityKind().getIcon(); + Drawable drawable = getContext().getDrawable(drawableId); + imageData.setImageData(IconHelper.getIconBytesFromDrawable(drawable)); + message.addDataStructure(imageData); + + return message; + } + + protected void setLanguage() { + Locale locale = getLocale(); + + conversationQueue.clear(); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_LOCALE, locale)); + conversationQueue.send(); + } + + private Locale getLocale() { + String localeString = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()) + .getString(PREF_LANGUAGE, PREF_LANGUAGE_AUTO); + + if (localeString.equals(PREF_LANGUAGE_AUTO)) { + localeString = java.util.Locale.getDefault().getLanguage(); + } + + String language = localeString.substring(0, 2); + switch (language) { + case "de": + case "en": + case "es": + case "fr": + case "it": + return new Locale(language); + default: + return new Locale("en"); + } + } + + private short getTimeMode() { + if ("24h".equals(getDevicePrefs().getTimeFormat())) { + return UserUnitConstants.UNIT_24H; + } else { + return UserUnitConstants.UNIT_12H; + } + } + + private short getUnit() { + String units = GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, GBApplication.getContext().getString(R.string.p_unit_metric)); + + if (units.equals(GBApplication.getContext().getString(R.string.p_unit_metric))) { + return UserUnitConstants.UNIT_KM; + } else { + return UserUnitConstants.UNIT_MILES; + } + } + + @Override + public boolean getImplicitCallbackModify() { + return true; + } + + @Override + public void onSetTime() { + conversationQueue.clear(); + addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time())); + conversationQueue.send(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java index 876bb4e3fc..59352a5a50 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/WithingsSteelHRDeviceSupport.java @@ -16,763 +16,26 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothGatt; -import android.bluetooth.BluetoothGattCharacteristic; -import android.bluetooth.BluetoothGattDescriptor; -import android.bluetooth.BluetoothGattService; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.List; - -import nodomain.freeyourgadget.gadgetbridge.GBApplication; -import nodomain.freeyourgadget.gadgetbridge.R; -import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsSteelHRSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; -import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; -import nodomain.freeyourgadget.gadgetbridge.model.Alarm; -import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; -import nodomain.freeyourgadget.gadgetbridge.model.DistanceUnit; -import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; -import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; -import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; -import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; -import nodomain.freeyourgadget.gadgetbridge.service.btle.ServerTransactionBuilder; -import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.WithingsActivityType; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.WithingsUUID; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ActivitySampleHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.BatteryStateHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.Conversation; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ConversationQueue; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.HeartRateHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ResponseHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SetupFinishedHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SimpleConversation; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SyncFinishedHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.WorkoutScreenListHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivityTarget; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmName; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmSettings; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmStatus; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AncsStatus; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.DataStructureFactory; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.EndOfTransmission; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.GetActivitySamples; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageData; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageMetaData; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.Locale; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.MoveHand; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.Probe; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ProbeOsVersion; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.Time; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.TypeVersion; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.User; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.UserUnit; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.UserUnitConstants; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WorkoutScreen; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.ExpectedResponse; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.MessageBuilder; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.MessageFactory; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.SimpleHexToByteMessage; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessage; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.IncomingMessageHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.IncomingMessageHandlerFactory; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming.LiveWorkoutHandler; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.GetNotificationAttributes; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.GetNotificationAttributesResponse; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.NotificationProvider; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.notification.NotificationSource; -import nodomain.freeyourgadget.gadgetbridge.util.GB; -import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; -import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.WithingsUUIDs; -import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE; -import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO; +/** + * Device support for Withings Steel HR. All protocol logic lives in {@link WithingsBaseDeviceSupport}. + */ +public class WithingsSteelHRDeviceSupport extends WithingsBaseDeviceSupport { -public class WithingsSteelHRDeviceSupport extends AbstractBTLESingleDeviceSupport { - - private static final Logger logger = LoggerFactory.getLogger(WithingsSteelHRDeviceSupport.class); - public static final String LAST_ACTIVITY_SYNC = "lastActivitySync"; - public static final String HANDS_CALIBRATION_CMD = "withings_hands_calibration"; - public static final String START_HANDS_CALIBRATION_CMD = "start_withings_hands_calibration"; - public static final String STOP_HANDS_CALIBRATION_CMD = "stop_withings_hands_calibration"; - private final MessageBuilder messageBuilder; - private LiveWorkoutHandler liveWorkoutHandler; - private ActivitySampleHandler activitySampleHandler; - private final ConversationQueue conversationQueue; - private boolean firstTimeConnect; - private BluetoothGattCharacteristic notificationSourceCharacteristic; - private BluetoothGattCharacteristic dataSourceCharacteristic; - private BluetoothDevice device; - private boolean syncInProgress; - private final ActivityUser activityUser; - private final NotificationProvider notificationProvider; - private final IncomingMessageHandlerFactory incomingMessageHandlerFactory; - private final Handler backgroundTasksHandler = new Handler(Looper.getMainLooper()); - - public WithingsSteelHRDeviceSupport() { - super(logger); - conversationQueue = new ConversationQueue(this); - notificationProvider = NotificationProvider.getInstance(this); - messageBuilder = new MessageBuilder(this, new MessageFactory(new DataStructureFactory())); - liveWorkoutHandler = new LiveWorkoutHandler(this); - incomingMessageHandlerFactory = IncomingMessageHandlerFactory.getInstance(this); - addSupportedService(WithingsUUID.WITHINGS_SERVICE_UUID); - addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS); - addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE); - addANCSService(); - activityUser = new ActivityUser(); - - IntentFilter commandFilter = new IntentFilter(HANDS_CALIBRATION_CMD); - commandFilter.addAction(START_HANDS_CALIBRATION_CMD); - commandFilter.addAction(STOP_HANDS_CALIBRATION_CMD); - final BroadcastReceiver commandReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (intent.getAction() == null) { - return; - } - - switch (intent.getAction()) { - case HANDS_CALIBRATION_CMD: - MoveHand moveHand = new MoveHand(); - moveHand.setHand(intent.getShortExtra("hand", (short) 1)); - moveHand.setMovement(intent.getShortExtra("movementAmount", (short) 1)); - sendToDevice(new WithingsMessage(WithingsMessageType.MOVE_HAND, moveHand)); - break; - case START_HANDS_CALIBRATION_CMD: - sendToDevice(new WithingsMessage(WithingsMessageType.START_HANDS_CALIBRATION)); - break; - case STOP_HANDS_CALIBRATION_CMD: - sendToDevice(new WithingsMessage(WithingsMessageType.STOP_HANDS_CALIBRATION)); - break; - } - } - }; - - LocalBroadcastManager.getInstance(GBApplication.getContext()).registerReceiver(commandReceiver, commandFilter); + @Override + protected WithingsUUIDs getWithingsUUIDs() { + return WithingsUUIDs.STEEL_HR; } @Override - public void dispose() { - synchronized (ConnectionMonitor) { - backgroundTasksHandler.removeCallbacksAndMessages(null); - - super.dispose(); - } - } - - @Override - protected TransactionBuilder initializeDevice(TransactionBuilder builder) { - logger.debug("Starting initialization..."); - conversationQueue.clear(); - builder.setDeviceState(GBDevice.State.INITIALIZING); - getDevice().setFirmwareVersion("N/A"); - getDevice().setFirmwareVersion2("N/A"); - - // Delay initialization with 2 seconds to give the watch time to settle - backgroundTasksHandler.removeCallbacksAndMessages(null); - backgroundTasksHandler.postDelayed(this::postConnectInitialization, 2000); - - return builder; - } - - private void postConnectInitialization() { - final TransactionBuilder builder = createTransactionBuilder("delayed initialization"); - builder.notify(WithingsUUID.WITHINGS_WRITE_CHARACTERISTIC_UUID, true); - builder.requestMtu(512); - builder.queue(); - } - - @Override - public boolean connectFirstTime() { - firstTimeConnect = true; - return connect(); - } - - @Override - public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { - super.onMtuChanged(gatt, mtu, status); - if (status != BluetoothGatt.GATT_SUCCESS) { - logger.error("Failed to change mtu - disconnecting"); - disconnect(); - return; - } - - logger.debug("MTU has changed to {}", mtu); - if (firstTimeConnect) { - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.INITIAL_CONNECT)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_LOCALE, getLocale())); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.START_HANDS_CALIBRATION)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.STOP_HANDS_CALIBRATION)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time())); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.DISTANCE, getUnit()))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.CLOCK_MODE, getTimeMode()))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(activityUser.getStepsGoal()))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ANCS_STATUS, new AncsStatus(true))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this)); - addScreenListCommands(); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SETUP_FINISHED), new SetupFinishedHandler(this)); - } else { - Message message = new WithingsMessage(WithingsMessageType.PROBE); - message.addDataStructure(new Probe((short) 1, (short) 1, 5100401)); - message.addDataStructure(new ProbeOsVersion((short) Build.VERSION.SDK_INT)); - conversationQueue.clear(); - addSimpleConversationToQueue(message, new AuthenticationHandler(this)); - } - - if (!firstTimeConnect) { - finishInitialization(); - } - conversationQueue.send(); - } - - public void doSync() { - activitySampleHandler = new ActivitySampleHandler(this); - conversationQueue.clear(); - try { - if (syncInProgress) { - return; - } - - getDevice().setBusyTask(R.string.busy_task_syncing, getContext()); - syncInProgress = true; - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.INITIAL_CONNECT)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time())); - - if (shoudSync()) { - logger.debug("Doing full sync..."); - WithingsMessage message = new WithingsMessage(WithingsMessageType.SET_USER); - message.addDataStructure(getUser()); - // The UserSecret appears in the original communication with the HealthMate app. Until now GB works without the secret. - // This makes the "authentication" far easier. However if it turns out that this is needed, we would need to find a way to savely store a unique generated secret. - // message.addDataStructure(new UserSecret()); - addSimpleConversationToQueue(message); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(activityUser.getStepsGoal()))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.DISTANCE, getUnit()))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.CLOCK_MODE, getTimeMode()))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM_SETTINGS)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_SCREEN_SETTINGS)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM_ENABLED)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_WORKOUT_SCREEN_LIST), new WorkoutScreenListHandler(this)); - Calendar c = Calendar.getInstance(); - c.setTimeInMillis(getLastSyncTimestamp()); - message = new WithingsMessage(WithingsMessageType.GET_ACTIVITY_SAMPLES, ExpectedResponse.EOT); - message.addDataStructure(new GetActivitySamples(c.getTimeInMillis() / 1000, (short) 0)); - addSimpleConversationToQueue(message, activitySampleHandler); - message = new WithingsMessage(WithingsMessageType.GET_MOVEMENT_SAMPLES, ExpectedResponse.EOT); - message.addDataStructure(new GetActivitySamples(c.getTimeInMillis() / 1000, (short) 0)); - message.addDataStructure(new TypeVersion()); - addSimpleConversationToQueue(message, activitySampleHandler); - message = new WithingsMessage(WithingsMessageType.GET_HEARTRATE_SAMPLES, ExpectedResponse.EOT); - message.addDataStructure(new GetActivitySamples(c.getTimeInMillis() / 1000, (short) 0)); - message.addDataStructure(new TypeVersion()); - addSimpleConversationToQueue(message, activitySampleHandler); - } - } catch (Exception e) { - logger.error("Could not synchronize! ", e); - conversationQueue.clear(); - } finally { - // This must be done in all cases or the watch won't respond anymore! - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SYNC_OK), new SyncFinishedHandler(this)); - } - conversationQueue.send(); - } - - - @Override - public boolean onCharacteristicChanged(BluetoothGatt gatt, - BluetoothGattCharacteristic characteristic, - byte[] data) { - if (super.onCharacteristicChanged(gatt, characteristic, data)) { - return true; - } - - boolean complete = messageBuilder.buildMessage(data); - if (complete) { - Message message = messageBuilder.getMessage(); - if (message.isIncomingMessage()) { - logger.debug("received incoming message: {}", message.getType()); - IncomingMessageHandler handler = incomingMessageHandlerFactory.getHandler(message); - handler.handleMessage(message); - } else { - conversationQueue.processResponse(message); - } - } - - return true; - } - - @Override - public void onSetCallState(CallSpec callSpec) { - if (callSpec.command == CallSpec.CALL_INCOMING) { - NotificationSpec notificationSpec = new NotificationSpec(); - notificationSpec.sourceAppId = "incoming.call"; - notificationSpec.title = callSpec.number; - notificationSpec.sender = callSpec.name; - notificationSpec.type = NotificationType.GENERIC_PHONE; - notificationProvider.notifyClient(notificationSpec); - } else { - logger.info("Received yet unhandled call command: " + callSpec.command); - } - } - - @Override - public void onNotification(NotificationSpec notificationSpec) { - notificationProvider.notifyClient(notificationSpec); - } - - @Override - public void onSetAlarms(ArrayList alarms) { - if (alarms.size() > 3) { - throw new IllegalArgumentException("Steel HR does only have three alarmslots!"); - } - - if (alarms.size() == 0) { - return; - } - - boolean noAlarmsEnabled = true; - conversationQueue.clear(); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM)); - for (Alarm alarm : alarms) { - if (alarm.getEnabled() && !alarm.getUnused()) { - noAlarmsEnabled = false; - addAlarm(alarm); - } - } - - if (noAlarmsEnabled) { - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(false))); - } - - conversationQueue.send(); - } - - @Override - public boolean onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { - this.device = device; - return true; - } - - @Override - public boolean onCharacteristicWriteRequest(BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) { - if (characteristic.getUuid().equals(WithingsUUID.CONTROL_POINT_CHARACTERISTIC_UUID)) { - logger.debug("Got GetNotificationAttributesRequest: " + GB.hexdump(value)); - GetNotificationAttributes request = new GetNotificationAttributes(); - request.deserialize(value); - notificationProvider.handleNotificationAttributeRequest(request); - } - - return true; - } - - @Override - public void onFetchRecordedData(int dataTypes) { - doSync(); - } - - @Override - public void onHeartRateTest() { - conversationQueue.clear(); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_HR), new HeartRateHandler(this)); - conversationQueue.send(); - } - - @Override - public void onSendConfiguration(String config) { - try { - switch (config) { - case HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE: - setWorkoutActivityTypes(); - break; - case PREF_LANGUAGE: - setLanguage(); - break; - default: - logger.debug("unknown configuration setting received: " + config); - } - } catch (Exception e) { - GB.toast("Error setting configuration", Toast.LENGTH_LONG, GB.ERROR, e); - } - } - - @Override - public void onTestNewFunction() { - String hexMessage = "0105080015050900111006040102030507000000000000000000"; - conversationQueue.clear(); - addSimpleConversationToQueue(new SimpleHexToByteMessage(hexMessage)); - conversationQueue.send(); - } - - @Override - public boolean useAutoConnect() { - return false; - } - - public void sendToDevice(Message message) { - if (message == null) { - return; - } - - try { - TransactionBuilder builder = createTransactionBuilder("conversation"); - builder.setCallback(this); - BluetoothGattCharacteristic characteristic = getCharacteristic(WithingsUUID.WITHINGS_WRITE_CHARACTERISTIC_UUID); - if (characteristic == null) { - logger.info("Characteristic with UUID " + WithingsUUID.WITHINGS_WRITE_CHARACTERISTIC_UUID + " not found."); - return; - } - - byte[] rawData = message.getRawData(); - builder.writeChunkedData(characteristic, rawData, getMTU() - 3); - builder.queue(); - } catch (Exception e) { - logger.warn("Could not send message because of " + e.getMessage()); - } - } - - public void sendAncsNotificationSourceNotification(NotificationSource notificationSource) { - try { - ServerTransactionBuilder builder = performServer("notificationSourceNotification"); - byte[] data = notificationSource.serialize(); - builder.notifyCharacteristicChanged(device, notificationSourceCharacteristic, data); - builder.queue(getQueue()); - } catch (IOException e) { - logger.error("Could not send notification.", e); - GB.toast("Could not send notification.", Toast.LENGTH_LONG, GB.ERROR, e); - } - } - - public void sendAncsDataSourceNotification(GetNotificationAttributesResponse response) { - try { - ServerTransactionBuilder builder = performServer("dataSourceNotification"); - byte[] data = response.serialize(); - builder.notifyCharacteristicChanged(device, dataSourceCharacteristic, data); - builder.queue(getQueue()); - } catch (IOException e) { - logger.error("Could not send notification.", e); - GB.toast("Could not send notification.", Toast.LENGTH_LONG, GB.ERROR, e); - } - } - - public void finishInitialization() { - TransactionBuilder builder = createTransactionBuilder("setupFinished"); - builder.setDeviceState(GBDevice.State.INITIALIZED); - builder.queue(); - logger.debug("Finished initialization."); - } - - public void finishSync() { - syncInProgress = false; - if (getDevice().isBusy()) { - getDevice().unsetBusyTask(); - getDevice().sendDeviceUpdateIntent(getContext()); - } - activitySampleHandler.onSyncFinished(); - saveLastSyncTimestamp(new Date().getTime()); - } - - void onAuthenticationFinished() { - if (!firstTimeConnect) { - addScreenListCommands(); - doSync(); - } else { - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ANCS_STATUS, new AncsStatus(true))); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS)); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this)); - conversationQueue.send(); - } - } - - private void addAlarm(Alarm alarm) { - AlarmSettings alarmSettings = new AlarmSettings(); - alarmSettings.setHour((short) alarm.getHour()); - alarmSettings.setMinute((short) alarm.getMinute()); - alarmSettings.setDayOfWeek(mapRepetitionToWithingsValue(alarm)); - if (alarm.getSmartWakeup()) { - // Healthmate has the possibility to change the minutecount, in GB we use a fixed value of 15 - alarmSettings.setSmartWakeupMinutes((short) 15); - } - - Message alarmMessage = new WithingsMessage(WithingsMessageType.SET_ALARM, alarmSettings); - if (!StringUtils.isEmpty(alarm.getTitle())) { - AlarmName alarmName = new AlarmName(alarm.getTitle()); - alarmMessage.addDataStructure(alarmName); - } - - addSimpleConversationToQueue(alarmMessage); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(true))); - } - - private short mapRepetitionToWithingsValue(Alarm alarm) { - int repetition = 0; - if (alarm.getRepetition(Alarm.ALARM_MON)) { - repetition += 0x02; - } - if (alarm.getRepetition(Alarm.ALARM_TUE)) { - repetition += 0x04; - } - if (alarm.getRepetition(Alarm.ALARM_WED)) { - repetition += 0x08; - } - if (alarm.getRepetition(Alarm.ALARM_THU)) { - repetition += 0x10; - } - if (alarm.getRepetition(Alarm.ALARM_FRI)) { - repetition += 0x20; - } - if (alarm.getRepetition(Alarm.ALARM_SAT)) { - repetition += 0x40; - } - if (alarm.getRepetition(Alarm.ALARM_SUN)) { - repetition += 0x01; - } - - return (short)(repetition + 0x80); - } - - private void addANCSService() { - BluetoothGattService withingsGATTService = new BluetoothGattService(WithingsUUID.WITHINGS_ANCS_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY); - notificationSourceCharacteristic = new BluetoothGattCharacteristic(WithingsUUID.NOTIFICATION_SOURCE_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ); - notificationSourceCharacteristic.addDescriptor(new BluetoothGattDescriptor(WithingsUUID.CCC_DESCRIPTOR_UUID, BluetoothGattCharacteristic.PERMISSION_WRITE)); - withingsGATTService.addCharacteristic(notificationSourceCharacteristic); - withingsGATTService.addCharacteristic(new BluetoothGattCharacteristic(WithingsUUID.CONTROL_POINT_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_WRITE, BluetoothGattCharacteristic.PERMISSION_WRITE)); - dataSourceCharacteristic = new BluetoothGattCharacteristic(WithingsUUID.DATA_SOURCE_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ); - dataSourceCharacteristic.addDescriptor(new BluetoothGattDescriptor(WithingsUUID.CCC_DESCRIPTOR_UUID, BluetoothGattCharacteristic.PERMISSION_WRITE)); - withingsGATTService.addCharacteristic(dataSourceCharacteristic); - addSupportedServerService(withingsGATTService); - } - - private void addSimpleConversationToQueue(Message message) { - addSimpleConversationToQueue(message, null); - } - - private void addSimpleConversationToQueue(Message message, ResponseHandler handler) { - Conversation conversation = new SimpleConversation(handler); - conversation.setRequest(message); - conversationQueue.addConversation(conversation); - } - - private void saveLastSyncTimestamp(@NonNull long timestamp) { - SharedPreferences.Editor editor = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()).edit(); - editor.putLong(LAST_ACTIVITY_SYNC, timestamp); - editor.apply(); - } - - private long getLastSyncTimestamp() { - SharedPreferences settings = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); - long lastSyncTime = settings.getLong(LAST_ACTIVITY_SYNC, 0); - if (lastSyncTime > 0) { - return lastSyncTime; - } else { - Date currentDate = new Date(); - Calendar c = Calendar.getInstance(); - c.setTimeInMillis(currentDate.getTime()); - c.add(Calendar.HOUR, - 10); - return c.getTimeInMillis(); - } - } - - private boolean shoudSync() { - long lastSynced = getLastSyncTimestamp(); - int minuteInMillis = 60 * 1000; - return new Date().getTime() - lastSynced > minuteInMillis; - } - - private User getUser() { - User user = new User(); - ActivityUser activityUser = new ActivityUser(); - user.setName(activityUser.getName()); - user.setGender((byte) activityUser.getGender()); - user.setHeight(activityUser.getHeightCm()); - user.setWeight(activityUser.getWeightKg()); - user.setBirthdate(activityUser.getUserBirthday()); - return user; - } - - private void addScreenListCommands() { - // TODO: this needs to be more reworked, at the moment for example the notification screen is always on and this is full of magic numbers that need to be identified properly: - Message message = new WithingsMessage(WithingsMessageType.SET_SCREEN_LIST); - ScreenSettings settings = new ScreenSettings(); - settings.setId(0xff); - settings.setIdOnDevice((byte)6); - message.addDataStructure(settings); - - settings = new ScreenSettings(); - settings.setId(0x3d); - settings.setIdOnDevice((byte)1); - message.addDataStructure(settings); - - settings = new ScreenSettings(); - settings.setId(0x33); - settings.setIdOnDevice((byte)4); - message.addDataStructure(settings); - - settings = new ScreenSettings(); - settings.setId(0x2d); - settings.setIdOnDevice((byte)2); - message.addDataStructure(settings); - - settings = new ScreenSettings(); - settings.setId(0x2a); - settings.setIdOnDevice((byte)3); - message.addDataStructure(settings); - - settings = new ScreenSettings(); - settings.setId(0x26); - settings.setIdOnDevice((byte)7); - message.addDataStructure(settings); - - settings = new ScreenSettings(); - settings.setId(0x39); - settings.setIdOnDevice((byte)9); - message.addDataStructure(settings); - - message.addDataStructure(new EndOfTransmission()); - addSimpleConversationToQueue(message); - } - - private void setWorkoutActivityTypes() { - final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()); - - final List allActivityTypes = Arrays.asList(getContext().getResources().getStringArray(R.array.pref_withings_steel_activity_types_values)); - final List defaultActivityTypes = Arrays.asList(getContext().getResources().getStringArray(R.array.pref_withings_steel_activity_types_default)); - final String activityTypesPref = prefs.getString("workout_activity_types_sortable", null); - - final List enabledActivityTypes; - if (activityTypesPref == null || activityTypesPref.equals("")) { - enabledActivityTypes = defaultActivityTypes; - } else { - enabledActivityTypes = Arrays.asList(activityTypesPref.split(",")); - } - - conversationQueue.clear(); - for (int i = 0; i < enabledActivityTypes.size(); i++) { - String workoutType = enabledActivityTypes.get(i); - try { - Message message = createWorkoutScreenMessage(workoutType); - if (i == enabledActivityTypes.size() - 1) { - message.addDataStructure(new EndOfTransmission()); - } - addSimpleConversationToQueue(message); - } catch (Exception e) { - logger.warn("exception in setWorkoutActivityTypes", e); - } - } - - conversationQueue.send(); - } - - @NonNull - private Message createWorkoutScreenMessage(String workoutType) { - WithingsActivityType withingsActivityType = WithingsActivityType.fromPrefValue(workoutType); - int code = withingsActivityType.getCode(); - Message message = new WithingsMessage(WithingsMessageType.SET_WORKOUT_SCREEN, ExpectedResponse.NONE); - WorkoutScreen workoutScreen = new WorkoutScreen(); - workoutScreen.setId(code); - final int stringId = getContext().getResources().getIdentifier("activity_type_" + workoutType, "string", getContext().getPackageName()); - workoutScreen.setName(getContext().getString(stringId)); - message.addDataStructure(workoutScreen); - - ImageMetaData imageMetaData = new ImageMetaData(); - imageMetaData.setHeight((byte)24); - imageMetaData.setWidth((byte)22); - message.addDataStructure(imageMetaData); - - ImageData imageData = new ImageData(); - final int drawableId = withingsActivityType.toActivityKind().getIcon(); - Drawable drawable = getContext().getDrawable(drawableId); - imageData.setImageData(IconHelper.getIconBytesFromDrawable(drawable)); - message.addDataStructure(imageData); - - return message; - } - - protected void setLanguage() { - Locale locale = getLocale(); - - conversationQueue.clear(); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_LOCALE, locale)); - conversationQueue.send(); - } - - private Locale getLocale() { - String localeString = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()) - .getString(PREF_LANGUAGE, PREF_LANGUAGE_AUTO); - - if (localeString.equals(PREF_LANGUAGE_AUTO)) { - localeString = java.util.Locale.getDefault().getLanguage(); - } - - String language = localeString.substring(0, 2); - switch (language) { - case "de": - case "en": - case "es": - case "fr": - case "it": - return new Locale(language); - default: - return new Locale("en"); - } - } - - private short getTimeMode() { - if ("24h".equals(getDevicePrefs().getTimeFormat())) { - return UserUnitConstants.UNIT_24H; - } else { - return UserUnitConstants.UNIT_12H; - } - } - - private short getUnit() { - final DistanceUnit distanceUnit = GBApplication.getPrefs().getDistanceUnit(); - - if (distanceUnit == DistanceUnit.METRIC) { - return UserUnitConstants.UNIT_KM; - } else { - return UserUnitConstants.UNIT_MILES; - } - } - - @Override - public boolean getImplicitCallbackModify() { - return true; - } - - @Override - public void onSetTime() { - conversationQueue.clear(); - addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time())); - conversationQueue.send(); + public AbstractSampleProvider createSampleProvider(GBDevice device, DaoSession session) { + return new WithingsSteelHRSampleProvider(device, session); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/SleepActivitySampleHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/SleepActivitySampleHelper.java index 8bde32bbf0..d2dd144986 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/SleepActivitySampleHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/SleepActivitySampleHelper.java @@ -18,36 +18,38 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.act import java.util.List; -import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsSteelHRSampleProvider; -import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySample; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; /** - * This class is needed for sleep tracking as the withings steel HR sends heartrate while sleeping in an extra activity. - * This leads to breaking the sleep session in the sleep calculation of GB. + * This class is needed for sleep tracking as the Withings Steel HR sends heartrate while sleeping + * in an extra activity. This leads to breaking the sleep session in the sleep calculation of GB. */ public class SleepActivitySampleHelper { - public static WithingsSteelHRActivitySample mergeIfNecessary(WithingsSteelHRSampleProvider provider, WithingsSteelHRActivitySample sample) { + public static T mergeIfNecessary( + AbstractSampleProvider provider, T sample) { if (!shouldMerge(sample)) { return sample; } - WithingsSteelHRActivitySample overlappingSample = getOverlappingSample(provider, (int)sample.getTimestamp()); + T overlappingSample = getOverlappingSample(provider, (int) sample.getTimestamp()); if (overlappingSample != null) { - sample = doMerge(overlappingSample, sample); + sample = doMerge(provider, overlappingSample, sample); } return sample; } - private static WithingsSteelHRActivitySample getOverlappingSample(WithingsSteelHRSampleProvider provider, long timestamp) { - List samples = provider.getActivitySamples((int)timestamp - 500, (int)timestamp); + private static T getOverlappingSample( + AbstractSampleProvider provider, long timestamp) { + List samples = provider.getActivitySamples((int) timestamp - 500, (int) timestamp); if (samples.isEmpty()) { return null; } - for (int i = samples.size()-1; i >= 0; i--) { - WithingsSteelHRActivitySample lastSample = samples.get(i); + for (int i = samples.size() - 1; i >= 0; i--) { + T lastSample = samples.get(i); if (isNotHeartRateOnly(lastSample)) { return lastSample; } @@ -56,11 +58,11 @@ public class SleepActivitySampleHelper { return null; } - private static boolean isNotHeartRateOnly(WithingsSteelHRActivitySample lastSample) { - return lastSample.getRawKind() != ActivityKind.NOT_MEASURED.getCode(); // && lastSample.getTimestamp() <= timestamp && (lastSample.getTimestamp() + lastSample.getDuration()) >= timestamp); + private static boolean isNotHeartRateOnly(AbstractWithingsActivitySample lastSample) { + return lastSample.getRawKind() != ActivityKind.NOT_MEASURED.getCode(); } - private static boolean shouldMerge(WithingsSteelHRActivitySample sample) { + private static boolean shouldMerge(AbstractWithingsActivitySample sample) { return sample.getSteps() == 0 && sample.getDistance() == 0 && sample.getRawKind() == -1 @@ -69,15 +71,14 @@ public class SleepActivitySampleHelper { && sample.getRawIntensity() == 0; } - private static WithingsSteelHRActivitySample doMerge(WithingsSteelHRActivitySample origin, WithingsSteelHRActivitySample update) { - WithingsSteelHRActivitySample mergeResult = new WithingsSteelHRActivitySample(); + private static T doMerge( + AbstractSampleProvider provider, T origin, T update) { + T mergeResult = provider.createActivitySample(); mergeResult.setTimestamp(update.getTimestamp()); mergeResult.setRawKind(origin.getRawKind()); mergeResult.setRawIntensity(origin.getRawIntensity()); mergeResult.setDuration(origin.getDuration() - (update.getTimestamp() - origin.getTimestamp())); - mergeResult.setDevice(origin.getDevice()); mergeResult.setDeviceId(origin.getDeviceId()); - mergeResult.setUser(origin.getUser()); mergeResult.setUserId(origin.getUserId()); mergeResult.setProvider(origin.getProvider()); mergeResult.setHeartRate(update.getHeartRate()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/WithingsActivityType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/WithingsActivityType.java index 3ea2eb0bb8..0a6ac4a398 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/WithingsActivityType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/activity/WithingsActivityType.java @@ -18,6 +18,9 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.act import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiWorkoutScreenActivityType; @@ -61,6 +64,8 @@ public enum WithingsActivityType { RIDING(26), OTHER(36); + private static final Logger LOG = LoggerFactory.getLogger(WithingsActivityType.class); + private final int code; WithingsActivityType(int typeCode) { @@ -73,7 +78,8 @@ public enum WithingsActivityType { return type; } } - throw new RuntimeException("No matching WithingsActivityType for code: " + withingsCode); + LOG.warn("No matching WithingsActivityType for code: {}, falling back to OTHER", withingsCode); + return OTHER; } public int getCode() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsUUIDs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsUUIDs.java new file mode 100644 index 0000000000..7832a20dcd --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/WithingsUUIDs.java @@ -0,0 +1,54 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.service.devices.withingssteelhr.communication; + +import java.util.UUID; + +/** + * Holds the device-specific BLE UUIDs for Withings devices. + * Steel HR uses suffix "0037", Scanwatch uses suffix "005d". + */ +public class WithingsUUIDs { + + public final UUID WITHINGS_SERVICE_UUID; + public final UUID WITHINGS_WRITE_CHARACTERISTIC_UUID; + public final UUID WITHINGS_APP_CHARACTERISTIC_UUID; + public final UUID WITHINGS_APP_CHARACTERISTIC2_UUID; + public final UUID CCC_DESCRIPTOR_UUID; + public final UUID WITHINGS_ANCS_SERVICE_UUID; + public final UUID NOTIFICATION_SOURCE_CHARACTERISTIC_UUID; + public final UUID CONTROL_POINT_CHARACTERISTIC_UUID; + public final UUID DATA_SOURCE_CHARACTERISTIC_UUID; + + public WithingsUUIDs(final String suffix) { + WITHINGS_SERVICE_UUID = UUID.fromString("00000020-5749-5448-" + suffix + "-000000000000"); + WITHINGS_WRITE_CHARACTERISTIC_UUID = UUID.fromString("00000024-5749-5448-" + suffix + "-000000000000"); + WITHINGS_APP_CHARACTERISTIC_UUID = UUID.fromString("10000059-5749-5448-" + suffix + "-000000000000"); + WITHINGS_APP_CHARACTERISTIC2_UUID = UUID.fromString("10000028-5749-5448-" + suffix + "-000000000000"); + CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"); + WITHINGS_ANCS_SERVICE_UUID = UUID.fromString("10000057-5749-5448-" + suffix + "-000000000000"); + NOTIFICATION_SOURCE_CHARACTERISTIC_UUID = UUID.fromString("10000059-5749-5448-" + suffix + "-000000000000"); + CONTROL_POINT_CHARACTERISTIC_UUID = UUID.fromString("10000058-5749-5448-" + suffix + "-000000000000"); + DATA_SOURCE_CHARACTERISTIC_UUID = UUID.fromString("1000005a-5749-5448-" + suffix + "-000000000000"); + } + + /** Steel HR UUID set */ + public static final WithingsUUIDs STEEL_HR = new WithingsUUIDs("0037"); + + /** Scanwatch UUID set */ + public static final WithingsUUIDs SCANWATCH = new WithingsUUIDs("005d"); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/AbstractResponseHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/AbstractResponseHandler.java index 107bbafb75..d26d525983 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/AbstractResponseHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/AbstractResponseHandler.java @@ -17,13 +17,13 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; public abstract class AbstractResponseHandler implements ResponseHandler { protected GBDevice device; - protected WithingsSteelHRDeviceSupport support; + protected WithingsBaseDeviceSupport support; - public AbstractResponseHandler(WithingsSteelHRDeviceSupport support) { + public AbstractResponseHandler(WithingsBaseDeviceSupport support) { this.support = support; this.device = support.getDevice(); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ActivitySampleHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ActivitySampleHandler.java index dbbe0ec44b..a41f5fa4e6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ActivitySampleHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ActivitySampleHandler.java @@ -25,10 +25,10 @@ import java.util.List; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; -import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsSteelHRSampleProvider; -import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySample; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.ActivityEntry; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.WithingsActivityType; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WorkoutType; @@ -52,7 +52,7 @@ public class ActivitySampleHandler extends AbstractResponseHandler { private List activityEntries = new ArrayList<>(); private List heartrateEntries = new ArrayList<>(); - public ActivitySampleHandler(WithingsSteelHRDeviceSupport support) { + public ActivitySampleHandler(WithingsBaseDeviceSupport support) { super(support); } @@ -200,32 +200,49 @@ public class ActivitySampleHandler extends AbstractResponseHandler { } private void saveData() { - List activitySamples = new ArrayList<>(); - for (ActivityEntry activityEntry : activityEntries) { - convertToSampleAndAddToList(activitySamples, activityEntry); - } - for (ActivityEntry activityEntry : heartrateEntries) { - convertToSampleAndAddToList(activitySamples, activityEntry); - } - - writeToDB(activitySamples); - } - - private void writeToDB(List activitySamples) { try (DBHandler dbHandler = GBApplication.acquireDB()) { Long userId = DBHelper.getUser(dbHandler.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, dbHandler.getDaoSession()).getId(); - WithingsSteelHRSampleProvider provider = new WithingsSteelHRSampleProvider(device, dbHandler.getDaoSession()); - for (WithingsSteelHRActivitySample sample : activitySamples) { - sample.setDeviceId(deviceId); - sample.setUserId(userId); + AbstractSampleProvider provider = + support.createSampleProvider(device, dbHandler.getDaoSession()); + + List activitySamples = new ArrayList<>(); + for (ActivityEntry entry : activityEntries) { + activitySamples.add(convertToSample(provider, entry, userId, deviceId)); } - provider.addGBActivitySamples(activitySamples.toArray(new WithingsSteelHRActivitySample[0])); + for (ActivityEntry entry : heartrateEntries) { + activitySamples.add(convertToSample(provider, entry, userId, deviceId)); + } + + writeToDB(provider, activitySamples); } catch (Exception ex) { logger.warn("Error saving activity data: " + ex.getLocalizedMessage()); } } + @SuppressWarnings("unchecked") + private void writeToDB( + AbstractSampleProvider provider, List activitySamples) { + provider.addGBActivitySamples((T[]) activitySamples.toArray(new AbstractWithingsActivitySample[0])); + } + + private AbstractWithingsActivitySample convertToSample( + AbstractSampleProvider provider, + ActivityEntry activityEntry, long userId, long deviceId) { + AbstractWithingsActivitySample sample = provider.createActivitySample(); + sample.setTimestamp(activityEntry.getTimestamp()); + sample.setDuration(activityEntry.getDuration()); + sample.setHeartRate(activityEntry.getHeartrate()); + sample.setSteps(activityEntry.getSteps()); + sample.setRawKind(activityEntry.getRawKind()); + sample.setCalories(activityEntry.getCalories()); + sample.setDistance(activityEntry.getDistance()); + sample.setRawIntensity(activityEntry.getRawIntensity()); + sample.setDeviceId(deviceId); + sample.setUserId(userId); + return sample; + } + private void mergeHeartrateSamplesIntoActivitySammples() { for (ActivityEntry heartrateEntry : heartrateEntries) { for (ActivityEntry activityEntry : activityEntries) { @@ -253,17 +270,4 @@ public class ActivitySampleHandler extends AbstractResponseHandler { heartRateEntry.setCalories(activityEntry.getCalories()); } } - - private void convertToSampleAndAddToList(List activitySamples, ActivityEntry activityEntry) { - WithingsSteelHRActivitySample sample = new WithingsSteelHRActivitySample(); - sample.setTimestamp(activityEntry.getTimestamp()); - sample.setDuration(activityEntry.getDuration()); - sample.setHeartRate(activityEntry.getHeartrate()); - sample.setSteps(activityEntry.getSteps()); - sample.setRawKind(activityEntry.getRawKind()); - sample.setCalories(activityEntry.getCalories()); - sample.setDistance(activityEntry.getDistance()); - sample.setRawIntensity(activityEntry.getRawIntensity()); - activitySamples.add(sample); - } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/BatteryStateHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/BatteryStateHandler.java index ad7bc5d5bd..9660f446c7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/BatteryStateHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/BatteryStateHandler.java @@ -19,13 +19,13 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.com import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.BatteryValues; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; public class BatteryStateHandler extends AbstractResponseHandler { - public BatteryStateHandler(WithingsSteelHRDeviceSupport support) { + public BatteryStateHandler(WithingsBaseDeviceSupport support) { super(support); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ConversationQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ConversationQueue.java index b26da83d62..84de19352c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ConversationQueue.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ConversationQueue.java @@ -21,16 +21,16 @@ import org.slf4j.LoggerFactory; import java.util.LinkedList; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; public class ConversationQueue implements ConversationObserver { private static final Logger logger = LoggerFactory.getLogger(ConversationQueue.class); private final LinkedList queue = new LinkedList<>(); - private WithingsSteelHRDeviceSupport support; + private WithingsBaseDeviceSupport support; - public ConversationQueue(WithingsSteelHRDeviceSupport support) { + public ConversationQueue(WithingsBaseDeviceSupport support) { this.support = support; } @@ -49,10 +49,12 @@ public class ConversationQueue implements ConversationObserver if (!queue.isEmpty()) { Conversation nextInLine = queue.peek(); if (nextInLine!= null) { - logger.debug("Sending next queued message."); + logger.debug("Sending next queued message type={} (0x{})", nextInLine.getRequest().getType(), Integer.toHexString(nextInLine.getRequest().getType() & 0xffff)); Message request = nextInLine.getRequest(); support.sendToDevice(request); } + } else { + logger.debug("Queue is empty, nothing to send."); } } @@ -62,17 +64,28 @@ public class ConversationQueue implements ConversationObserver } if (conversation.getRequest().needsResponse() || conversation.getRequest().needsEOT()) { + logger.debug("addConversation: queuing type={} (0x{}) needsResponse={} needsEOT={}", conversation.getRequest().getType(), Integer.toHexString(conversation.getRequest().getType() & 0xffff), conversation.getRequest().needsResponse(), conversation.getRequest().needsEOT()); queue.add(conversation); conversation.registerObserver(this); } else { + logger.debug("addConversation: fire-and-forget type={} (0x{})", conversation.getRequest().getType(), Integer.toHexString(conversation.getRequest().getType() & 0xffff)); support.sendToDevice(conversation.getRequest()); } } public void processResponse(Message response) { + logger.debug("processResponse: type={} (0x{}), queue size={}", response.getType(), Integer.toHexString(response.getType() & 0xffff), queue.size()); + for (Conversation c : queue) { + if (c.getRequest() != null) { + logger.debug(" queued conversation request type={} (0x{})", c.getRequest().getType(), Integer.toHexString(c.getRequest().getType() & 0xffff)); + } + } Conversation conversation = getConversation(response.getType()); if (conversation != null) { + logger.debug("processResponse: matched conversation for type={}", response.getType()); conversation.handleResponse(response); + } else { + logger.warn("processResponse: no conversation found for type={} (0x{}) -- message dropped!", response.getType(), Integer.toHexString(response.getType() & 0xffff)); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/GetShortcutHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/GetShortcutHandler.java new file mode 100644 index 0000000000..cf2c0aa651 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/GetShortcutHandler.java @@ -0,0 +1,69 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.service.devices.withingssteelhr.communication.conversation; + +import android.content.SharedPreferences; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ShortcutAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; + +/** + * Handles the response to {@code GET_SHORTCUT} (cmd 0x0992). + * + *

Reads the current long-press crown shortcut action from the watch and persists it to the + * device-specific {@link SharedPreferences} under + * {@link #PREF_SHORTCUT_ACTION} so that the UI can display the current value. + */ +public class GetShortcutHandler extends AbstractResponseHandler { + + private static final Logger logger = LoggerFactory.getLogger(GetShortcutHandler.class); + + /** Device-specific shared-prefs key for the shortcut action byte (stored as int). */ + public static final String PREF_SHORTCUT_ACTION = "withings_scanwatch_shortcut_action"; + + public GetShortcutHandler(WithingsBaseDeviceSupport support) { + super(support); + } + + @Override + public void handleResponse(Message response) { + List data = response.getDataStructures(); + if (data == null || data.isEmpty()) { + logger.warn("GetShortcutHandler: received empty response"); + return; + } + for (WithingsStructure structure : data) { + if (structure instanceof ShortcutAction) { + final byte action = ((ShortcutAction) structure).getAction(); + logger.info("GetShortcutHandler: current shortcut action = {}", action); + final SharedPreferences prefs = + GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()); + prefs.edit() + .putString(PREF_SHORTCUT_ACTION, String.valueOf(action)) + .apply(); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/HeartRateHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/HeartRateHandler.java index 55777eda71..c484ec9cca 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/HeartRateHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/HeartRateHandler.java @@ -28,11 +28,11 @@ import java.util.GregorianCalendar; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; -import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsSteelHRSampleProvider; -import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySample; -import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.SleepActivitySampleHelper; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.HeartRate; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LiveHeartRate; @@ -42,7 +42,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm public class HeartRateHandler extends AbstractResponseHandler { private static final Logger logger = LoggerFactory.getLogger(HeartRateHandler.class); - public HeartRateHandler(WithingsSteelHRDeviceSupport support) { + public HeartRateHandler(WithingsBaseDeviceSupport support) { super(support); } @@ -56,9 +56,9 @@ public class HeartRateHandler extends AbstractResponseHandler { private void handleHeartRateData(WithingsStructure structure) { int heartRate = 0; if (structure instanceof HeartRate) { - heartRate = ((HeartRate)structure).getHeartrate(); + heartRate = ((HeartRate) structure).getHeartrate(); } else if (structure instanceof LiveHeartRate) { - heartRate = ((LiveHeartRate)structure).getHeartrate(); + heartRate = ((LiveHeartRate) structure).getHeartrate(); } if (heartRate > 0) { @@ -66,21 +66,22 @@ public class HeartRateHandler extends AbstractResponseHandler { } } - private void saveHeartRateData(int heartRate) { - WithingsSteelHRActivitySample sample = new WithingsSteelHRActivitySample(); - sample.setTimestamp((int) (GregorianCalendar.getInstance().getTimeInMillis() / 1000L)); - sample.setHeartRate(heartRate); + private void saveHeartRateData(int heartRate) { try (DBHandler dbHandler = GBApplication.acquireDB()) { Long userId = DBHelper.getUser(dbHandler.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, dbHandler.getDaoSession()).getId(); - WithingsSteelHRSampleProvider provider = new WithingsSteelHRSampleProvider(device, dbHandler.getDaoSession()); + AbstractSampleProvider provider = + (AbstractSampleProvider) support.createSampleProvider(device, dbHandler.getDaoSession()); + T sample = provider.createActivitySample(); + sample.setTimestamp((int) (GregorianCalendar.getInstance().getTimeInMillis() / 1000L)); + sample.setHeartRate(heartRate); sample.setDeviceId(deviceId); sample.setUserId(userId); sample = SleepActivitySampleHelper.mergeIfNecessary(provider, sample); provider.addGBActivitySample(sample); Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) .putExtra(GBDevice.EXTRA_DEVICE, support.getDevice()) - .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample); + .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, (java.io.Serializable) sample); LocalBroadcastManager.getInstance(support.getContext()).sendBroadcast(intent); } catch (Exception ex) { logger.warn("Error saving current heart rate: " + ex.getLocalizedMessage()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ScreenSettingsHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ScreenSettingsHandler.java new file mode 100644 index 0000000000..5b88138452 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/ScreenSettingsHandler.java @@ -0,0 +1,55 @@ +/* Copyright (C) 2023-2024 Frank Ertl + + 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.service.devices.withingssteelhr.communication.conversation; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; + +public class ScreenSettingsHandler extends AbstractResponseHandler { + + private static final Logger logger = LoggerFactory.getLogger(ScreenSettingsHandler.class); + + public ScreenSettingsHandler(WithingsBaseDeviceSupport support) { + super(support); + } + + @Override + public void handleResponse(Message response) { + List data = response.getDataStructures(); + if (data == null || data.isEmpty()) { + logger.warn("ScreenSettingsHandler: received empty response"); + return; + } + logger.info("ScreenSettingsHandler: device reported {} screen(s)", data.size()); + for (WithingsStructure structure : data) { + if (structure instanceof ScreenSettings) { + ScreenSettings screen = (ScreenSettings) structure; + logger.info("Screen from device: id=0x{} ({}), slot={}", + Integer.toHexString(screen.getId()), + screen.getId(), + screen.getIdOnDevice() & 0xFF); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SetupFinishedHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SetupFinishedHandler.java index 6d3f2aa99b..b11437c8cd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SetupFinishedHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SetupFinishedHandler.java @@ -16,13 +16,13 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType; public class SetupFinishedHandler extends AbstractResponseHandler { - public SetupFinishedHandler(WithingsSteelHRDeviceSupport support) { + public SetupFinishedHandler(WithingsBaseDeviceSupport support) { super(support); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SyncFinishedHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SyncFinishedHandler.java index 6c0bcf55ed..146574af90 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SyncFinishedHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/SyncFinishedHandler.java @@ -16,13 +16,13 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType; public class SyncFinishedHandler extends AbstractResponseHandler { - public SyncFinishedHandler(WithingsSteelHRDeviceSupport support) { + public SyncFinishedHandler(WithingsBaseDeviceSupport support) { super(support); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/WorkoutScreenListHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/WorkoutScreenListHandler.java index 56cc8a7477..e487a9d7fa 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/WorkoutScreenListHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/conversation/WorkoutScreenListHandler.java @@ -25,7 +25,7 @@ import java.util.Locale; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.WithingsActivityType; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WorkoutScreenList; @@ -33,7 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm public class WorkoutScreenListHandler extends AbstractResponseHandler { - public WorkoutScreenListHandler(WithingsSteelHRDeviceSupport support) { + public WorkoutScreenListHandler(WithingsBaseDeviceSupport support) { super(support); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/DataStructureFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/DataStructureFactory.java index 251f8d1104..b1b618814a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/DataStructureFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/DataStructureFactory.java @@ -128,6 +128,9 @@ public class DataStructureFactory { case WithingsStructureType.NOTIFICATION_APP_ID: structure = new GlyphId(); break; + case WithingsStructureType.SHORTCUT_ACTION: + structure = new ShortcutAction(); + break; default: structure = null; logger.info("Received yet unknown structure type: " + structureTypeFromResponse); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ScreenSettings.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ScreenSettings.java index 6300379a58..1d1dd1fe21 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ScreenSettings.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ScreenSettings.java @@ -22,7 +22,6 @@ public class ScreenSettings extends WithingsStructure { private int id; - // TODO change to an actual unique ID. Must then be changed in User too. private int userId = 123456; private int yetUnknown1 = 0; private int yetUnknown2 = 0; @@ -37,6 +36,14 @@ public class ScreenSettings extends WithingsStructure { this.id = id; } + public int getUserId() { + return userId; + } + + public void setUserId(int userId) { + this.userId = userId; + } + public byte getIdOnDevice() { return idOnDevice; } @@ -50,6 +57,16 @@ public class ScreenSettings extends WithingsStructure { return 22; } + @Override + protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) { + this.id = rawDataBuffer.getInt(); + this.userId = rawDataBuffer.getInt(); + this.yetUnknown1 = rawDataBuffer.getInt(); + this.yetUnknown2 = rawDataBuffer.getInt(); + this.idOnDevice = rawDataBuffer.get(); + this.yetUnkown3 = rawDataBuffer.get(); + } + @Override protected void fillinTypeSpecificData(ByteBuffer buffer) { buffer.putInt(this.id); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ShortcutAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ShortcutAction.java new file mode 100644 index 0000000000..db1e5d896e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/ShortcutAction.java @@ -0,0 +1,97 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.service.devices.withingssteelhr.communication.datastructures; + +import java.nio.ByteBuffer; + +/** + * Encodes the long-press crown shortcut action (TLV type 0x09A1). + * + *

Wire format: 2-byte type (0x09A1) + 2-byte length (0x0001) + 1-byte action value. + * + *

Known action values: + *

+ *   0  = NONE
+ *   1  = ECG_MEAS
+ *   2  = SPO2_MEAS
+ *   3  = WORKOUT_START
+ *   4  = WORKOUT_SELECTION
+ *   5  = BREATH
+ *   6  = STOPWATCH
+ *   7  = TIMER
+ *   8  = DND
+ *   9  = QUICKLOOK
+ *   10 = FINDMYPHONE
+ *   11 = FLASHLIGHT
+ * 
+ */ +public class ShortcutAction extends WithingsStructure { + + // Action constants matching the Withings WPP protocol + public static final byte ACTION_NONE = 0; + // TODO: ACTION_ECG_MEAS and ACTION_SPO2_MEAS require the user to have accepted the health + // terms & conditions inside the official Withings app before the watch will honour + // them via BLE. Sending these values without prior T&C acceptance has no effect. + public static final byte ACTION_ECG_MEAS = 1; + public static final byte ACTION_SPO2_MEAS = 2; + public static final byte ACTION_WORKOUT_START = 3; + public static final byte ACTION_WORKOUT_SELECTION = 4; + public static final byte ACTION_BREATH = 5; + public static final byte ACTION_STOPWATCH = 6; + public static final byte ACTION_TIMER = 7; + public static final byte ACTION_DND = 8; + public static final byte ACTION_QUICKLOOK = 9; + public static final byte ACTION_FINDMYPHONE = 10; + public static final byte ACTION_FLASHLIGHT = 11; + + private byte action; + + /** No-arg constructor required by {@link DataStructureFactory}. */ + public ShortcutAction() {} + + public ShortcutAction(byte action) { + this.action = action; + } + + /** @return the action byte received from the watch */ + public byte getAction() { + return action; + } + + @Override + public short getLength() { + // 4-byte TLV header + 1 byte payload + return 5; + } + + @Override + protected void fillinTypeSpecificData(ByteBuffer buffer) { + buffer.put(action); + } + + @Override + protected void fillFromRawDataAsBuffer(ByteBuffer buffer) { + if (buffer.remaining() >= 1) { + action = buffer.get(); + } + } + + @Override + public short getType() { + return WithingsStructureType.SHORTCUT_ACTION; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsScreenId.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsScreenId.java new file mode 100644 index 0000000000..7afb170199 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsScreenId.java @@ -0,0 +1,135 @@ +/* Copyright (C) 2024 Gadgetbridge contributors + + 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.service.devices.withingssteelhr.communication.datastructures; + +/** + * Known screen IDs used in the Withings protocol SET_SCREEN_LIST command (message type 1292). + * + *

Each constant represents the {@code id} field sent inside a {@link ScreenSettings} structure + * (bytes 2-3 of the 18-byte TYPE_SCREEN_LIST payload, i.e. the lower 16 bits of the 4-byte int). + * The {@code idOnDevice} field (slot/position) is a separate fixed value that determines the + * display order on the watch (ascending slot = earlier in the list). + * + *

ScanWatch screen IDs were confirmed from {@code reorder_screens.zip}: a BLE capture + * of the Withings HealthMate app sending CMD_SCREEN_LIST_SET (0x050C) with all 13 screens enabled + * in the order: data, sleep, ecg, elevation, heart rate, spo2, calories, settings, workouts, + * distance, steps, breathe, clock. Sorting the 13 TYPE_SCREEN_LIST entries by ascending slot + * number maps 1-to-1 onto that order, giving confirmed ID <-> name <-> slot bindings. + * + *

Steel HR screen IDs come from the original Gadgetbridge implementation and have not + * been reverified against a fresh packet capture. + */ +public final class WithingsScreenId { + + private WithingsScreenId() {} + + // ----------------------------------------------------------------------- + // Screens - Withings Steel HR (source: original GB implementation) + // ----------------------------------------------------------------------- + + /** Notification screen. */ + public static final int NOTIFICATIONS = 0xFF; + + /** Heart rate (BPM) screen. */ + public static final int HEART_RATE_STEEL = 0x3D; + + /** Activity / step counter screen. */ + public static final int ACTIVITY = 0x33; + + /** Calories screen. */ + public static final int CALORIES_STEEL = 0x2D; + + /** Alarm/timer screen. */ + public static final int ALARM = 0x2A; + + /** Config / battery info screen. */ + public static final int CONFIG = 0x26; + + /** Chronometer / countdown screen. */ + public static final int CHRONOMETER = 0x39; + + // ----------------------------------------------------------------------- + // Screens - Withings ScanWatch (confirmed from reorder_screens.zip capture) + // Fixed slot numbers are the watch's internal position values; display order + // follows ascending slot number. + // ----------------------------------------------------------------------- + + /** Date / activity data screen. Screen ID 0x007f, fixed slot 1. */ + public static final int DATE = 0x007f; + + /** Sleep screen. Screen ID 0x0080, fixed slot 2. */ + public static final int SLEEP = 0x0080; + + /** ECG screen. Screen ID 0x0081, fixed slot 3. */ + public static final int ECG = 0x0081; + + /** Elevation screen. Screen ID 0x0082, fixed slot 4. */ + public static final int ELEVATION = 0x0082; + + /** Heart rate screen. Screen ID 0x0084, fixed slot 6. */ + public static final int HEART_RATE = 0x0084; + + /** SpO2 / blood oxygen screen. Screen ID 0x008a, fixed slot 9. */ + public static final int SPO2 = 0x008a; + + /** Calories screen. Screen ID 0x008b, fixed slot 10. */ + public static final int CALORIES = 0x008b; + + /** Settings screen. Screen ID 0x0089, fixed slot 11. */ + public static final int SETTINGS = 0x0089; + + /** Workouts screen. Screen ID 0x008c, fixed slot 12. */ + public static final int WORKOUTS = 0x008c; + + /** Distance screen. Screen ID 0x0096, fixed slot 16. */ + public static final int DISTANCE = 0x0096; + + /** Steps screen. Screen ID 0x0097, fixed slot 17. */ + public static final int STEPS = 0x0097; + + /** Breathe / breathing exercises screen. Screen ID 0x00a1, fixed slot 18. */ + public static final int BREATHE = 0x00a1; + + /** Clock (alarms, stopwatch, timer) screen. Screen ID 0x0160, fixed slot 22. */ + public static final int CLOCK = 0x0160; + + /** + * Returns the fixed slot number for a given ScanWatch screen ID. + * Slot numbers are the watch's internal position values; display order follows ascending slot. + * + * @param screenId one of the ScanWatch screen ID constants in this class + * @return the fixed slot byte, or -1 if the screen ID is not a known ScanWatch screen + */ + public static byte getScanwatchSlot(int screenId) { + switch (screenId) { + case DATE: return 1; + case SLEEP: return 2; + case ECG: return 3; + case ELEVATION: return 4; + case HEART_RATE: return 6; + case SPO2: return 9; + case CALORIES: return 10; + case SETTINGS: return 11; + case WORKOUTS: return 12; + case DISTANCE: return 16; + case STEPS: return 17; + case BREATHE: return 18; + case CLOCK: return 22; + default: return -1; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsStructureType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsStructureType.java index 6ae75c9254..e9022111c2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsStructureType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/datastructures/WithingsStructureType.java @@ -68,5 +68,8 @@ public class WithingsStructureType { public static final short WORKOUT_SCREEN_LIST = 316; public static final short WORKOUT_SCREEN_DATA = 317; + /** Long-press crown shortcut action (type 0x09A1 = 2465). */ + public static final short SHORTCUT_ACTION = (short) 0x09A1; // 2465 + private WithingsStructureType() {} } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/GlyphRequestHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/GlyphRequestHandler.java index 01a6353a00..9356ae9ef7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/GlyphRequestHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/GlyphRequestHandler.java @@ -26,7 +26,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.IconHelper; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.GlyphId; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageData; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageMetaData; @@ -35,9 +35,9 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; public class GlyphRequestHandler implements IncomingMessageHandler { private static final Logger logger = LoggerFactory.getLogger(GlyphRequestHandler.class); - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; - public GlyphRequestHandler(WithingsSteelHRDeviceSupport support) { + public GlyphRequestHandler(WithingsBaseDeviceSupport support) { this.support = support; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageBuilder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageBuilder.java index 53ca9ab196..294956d7c1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageBuilder.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageBuilder.java @@ -24,18 +24,18 @@ import java.io.IOException; import java.util.Arrays; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; public class MessageBuilder { private static final Logger logger = LoggerFactory.getLogger(MessageBuilder.class); - private WithingsSteelHRDeviceSupport support; + private WithingsBaseDeviceSupport support; private MessageFactory messageFactory; private ByteArrayOutputStream pendingMessage; private Message message; - public MessageBuilder(WithingsSteelHRDeviceSupport support, MessageFactory messageFactory) { + public MessageBuilder(WithingsBaseDeviceSupport support, MessageFactory messageFactory) { this.support = support; this.messageFactory = messageFactory; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageFactory.java index 8032812a2f..05da501693 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/MessageFactory.java @@ -43,6 +43,13 @@ public class MessageFactory { short messageTypeFromResponse = (short) (BLETypeConversions.toInt16(rawData[2], rawData[1]) & 16383); short totalDataLength = (short) BLETypeConversions.toInt16(rawData[4], rawData[3]); boolean isIncoming = rawData[1] == 65 || rawData[1] == -127; + logger.debug("createMessageFromRawData: rawData[1]=0x{} rawData[2]=0x{} -> type={} (0x{}) isIncoming={} dataLen={}", + Integer.toHexString(rawData[1] & 0xff), + Integer.toHexString(rawData[2] & 0xff), + messageTypeFromResponse, + Integer.toHexString(messageTypeFromResponse & 0xffff), + isIncoming, + totalDataLength); Message message = new WithingsMessage(messageTypeFromResponse, isIncoming); byte[] rawStructureData = Arrays.copyOfRange(rawData, 5, rawData.length); List structures = dataStructureFactory.createStructuresFromRawData(rawStructureData); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/WithingsMessageType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/WithingsMessageType.java index d45de5ccb8..79e0347942 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/WithingsMessageType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/WithingsMessageType.java @@ -64,5 +64,10 @@ public final class WithingsMessageType { public static final short GET_NOTIFICATION = 2404; public static final short GET_UNICODE_GLYPH = 2403; + /** Get the currently configured long-press crown shortcut action (cmd 0x0992). */ + public static final short GET_SHORTCUT = (short) 0x0992; // 2450 + /** Set the long-press crown shortcut action (cmd 0x0989). */ + public static final short SET_SHORTCUT = (short) 0x0989; // 2441 + private WithingsMessageType() {} } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/IncomingMessageHandlerFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/IncomingMessageHandlerFactory.java index e686721dab..ade213540c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/IncomingMessageHandlerFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/IncomingMessageHandlerFactory.java @@ -22,7 +22,7 @@ import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.GlyphRequestHandler; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType; @@ -31,14 +31,14 @@ public class IncomingMessageHandlerFactory { private static final Logger logger = LoggerFactory.getLogger(IncomingMessageHandlerFactory.class); private static IncomingMessageHandlerFactory instance; - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; private Map handlers = new HashMap<>(); - private IncomingMessageHandlerFactory(WithingsSteelHRDeviceSupport support) { + private IncomingMessageHandlerFactory(WithingsBaseDeviceSupport support) { this.support = support; } - public static IncomingMessageHandlerFactory getInstance(WithingsSteelHRDeviceSupport support) { + public static IncomingMessageHandlerFactory getInstance(WithingsBaseDeviceSupport support) { if (instance == null) { instance = new IncomingMessageHandlerFactory(support); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveHeartrateHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveHeartrateHandler.java index f835d4cc20..584c6d6c74 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveHeartrateHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveHeartrateHandler.java @@ -33,7 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsStee import nodomain.freeyourgadget.gadgetbridge.entities.WithingsSteelHRActivitySample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.SleepActivitySampleHelper; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LiveHeartRate; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure; @@ -41,9 +41,9 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm public class LiveHeartrateHandler implements IncomingMessageHandler { private static final Logger logger = LoggerFactory.getLogger(LiveHeartrateHandler.class); - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; - public LiveHeartrateHandler(WithingsSteelHRDeviceSupport support) { + public LiveHeartrateHandler(WithingsBaseDeviceSupport support) { this.support = support; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveWorkoutHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveWorkoutHandler.java index 8935e761e9..a3099f4821 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveWorkoutHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/LiveWorkoutHandler.java @@ -32,7 +32,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksController; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.WithingsActivityType; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LiveWorkoutEnd; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LiveWorkoutPauseState; @@ -48,10 +48,10 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; public class LiveWorkoutHandler implements IncomingMessageHandler { private static final Logger logger = LoggerFactory.getLogger(LiveWorkoutHandler.class); - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; private BaseActivitySummary baseActivitySummary; - public LiveWorkoutHandler(WithingsSteelHRDeviceSupport support) { + public LiveWorkoutHandler(WithingsBaseDeviceSupport support) { this.support = support; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/NotificationRequestHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/NotificationRequestHandler.java index 4297e498e7..0d103e6c1c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/NotificationRequestHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/NotificationRequestHandler.java @@ -29,7 +29,7 @@ import java.util.Map; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.IconHelper; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageData; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageMetaData; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.SourceAppId; @@ -42,10 +42,10 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; public class NotificationRequestHandler implements IncomingMessageHandler { private static final Logger logger = LoggerFactory.getLogger(NotificationRequestHandler.class); - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; private Map appIconCache = new HashMap<>(); - public NotificationRequestHandler(WithingsSteelHRDeviceSupport support) { + public NotificationRequestHandler(WithingsBaseDeviceSupport support) { this.support = support; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/SyncRequestHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/SyncRequestHandler.java index c3a85fe445..68707470c8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/SyncRequestHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/message/incoming/SyncRequestHandler.java @@ -16,7 +16,7 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.incoming; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.ExpectedResponse; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message; import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessage; @@ -24,9 +24,9 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm public class SyncRequestHandler implements IncomingMessageHandler { - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; - public SyncRequestHandler(WithingsSteelHRDeviceSupport support) { + public SyncRequestHandler(WithingsBaseDeviceSupport support) { this.support = support; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationProvider.java index 89108d25bd..65c9928b4f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/withingssteelhr/communication/notification/NotificationProvider.java @@ -25,17 +25,17 @@ import java.util.Map; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; -import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsSteelHRDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class NotificationProvider { private static final Logger logger = LoggerFactory.getLogger(NotificationProvider.class); - private final WithingsSteelHRDeviceSupport support; + private final WithingsBaseDeviceSupport support; private final Map pendingNotifications = new HashMap<>(); private static NotificationProvider instance; - public static NotificationProvider getInstance(WithingsSteelHRDeviceSupport support) { + public static NotificationProvider getInstance(WithingsBaseDeviceSupport support) { if (instance == null) { instance = new NotificationProvider(support); } @@ -43,7 +43,7 @@ public class NotificationProvider { return instance; } - private NotificationProvider(WithingsSteelHRDeviceSupport support) { + private NotificationProvider(WithingsBaseDeviceSupport support) { this.support = support; } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 7ebe8f9867..54b6e29127 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1834,6 +1834,86 @@ other + + + @string/withings_scanwatch_screen_date + @string/withings_scanwatch_screen_sleep + @string/withings_scanwatch_screen_ecg + @string/withings_scanwatch_screen_elevation + @string/withings_scanwatch_screen_heart_rate + @string/withings_scanwatch_screen_spo2 + @string/withings_scanwatch_screen_calories + @string/withings_scanwatch_screen_settings + @string/withings_scanwatch_screen_workouts + @string/withings_scanwatch_screen_distance + @string/withings_scanwatch_screen_steps + @string/withings_scanwatch_screen_breathe + @string/withings_scanwatch_screen_clock + + + + date + sleep + ecg + elevation + heart_rate + spo2 + calories + settings + workouts + distance + steps + breathe + clock + + + + + date + sleep + ecg + elevation + heart_rate + spo2 + calories + settings + workouts + distance + steps + breathe + clock + + + + + @string/withings_scanwatch_shortcut_none + @string/withings_scanwatch_shortcut_ecg + @string/withings_scanwatch_shortcut_spo2 + @string/withings_scanwatch_shortcut_workout_start + @string/withings_scanwatch_shortcut_workout_selection + @string/withings_scanwatch_shortcut_breathe + @string/withings_scanwatch_shortcut_stopwatch + @string/withings_scanwatch_shortcut_timer + @string/withings_scanwatch_shortcut_dnd + @string/withings_scanwatch_shortcut_quicklook + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + @string/menuitem_pai @string/menuitem_dnd diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8480784813..bbb712fbd9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3486,6 +3486,7 @@ 1MORE SonoFlow Sony Wena 3 Withings Steel HR + Withings Scanwatch Disabled Media Play Media Pause @@ -3736,6 +3737,37 @@ Finally align the activity hand to 100%. Please be aware that this hand only moves clockwise. Previous Next + Watch screens + Choose which screens appear on the watch and drag to reorder them + Date + Sleep + ECG + Elevation + Heart rate + SpO2 / Blood oxygen + Calories + Settings + Workouts + Distance + Steps + Breathe + Clock + + + Long-press crown shortcut + Action launched by holding the crown button + None + ECG measurement + SpO2 / Blood oxygen measurement + Start workout + Workout selection + Breathe + Stopwatch + Timer + Do not disturb + Quick look + Find my phone + Flashlight drag handle FOUND IT Trigger a full sync of all activity data diff --git a/app/src/main/res/xml/devicesettings_withingsscanwatch.xml b/app/src/main/res/xml/devicesettings_withingsscanwatch.xml new file mode 100644 index 0000000000..1e17b474f3 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_withingsscanwatch.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java index aa81327d61..7e9dc7ce83 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java @@ -19,6 +19,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase { final Map bluetoothNameToExpectedType = new HashMap<>() {{ put("Active 2 NFC (Round)", DeviceType.AMAZFITACTIVE2NFC); put("Amazfit Band 7", DeviceType.AMAZFITBAND7); // #2945 + put("ScanWatch CA", DeviceType.WITHINGS_SCANWATCH); put("Amazfit GTR 3 Pro", DeviceType.AMAZFITGTR3PRO); // #2442 put("Amazfit GTS", DeviceType.AMAZFITGTS); // #5391 put("Amazfit GTS 3", DeviceType.AMAZFITGTS3); // #2442