feat(withings,scanwatch): Initial support Withings Scanwatch

Pairing
Shortcut press-and-hold crown
This commit is contained in:
d3vv3
2026-03-28 13:15:06 +01:00
parent da878deea5
commit d74e2c53ee
48 changed files with 2167 additions and 886 deletions
@@ -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;
@@ -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() {
@@ -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);
@@ -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;
@@ -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
@@ -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
@@ -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 <https://www.gnu.org/licenses/>. */
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<AbstractDao<?, ?>, Property> getAllDeviceDao(@NonNull final DaoSession session) {
final Map<AbstractDao<?, ?>, 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<? extends ActivitySample> 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<? extends DeviceSupport> 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;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<WithingsScanwatchActivitySample> {
public WithingsScanwatchSampleProvider(GBDevice device, DaoSession session) {
super(device, session);
}
@Override
public AbstractDao<WithingsScanwatchActivitySample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<String> getPreferenceKeysWithSummary() {
return Collections.emptySet();
}
public static final Creator<WithingsScanwatchSettingsCustomizer> CREATOR = new Creator<WithingsScanwatchSettingsCustomizer>() {
@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) {
}
}
@@ -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<GBDevice> 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);
@@ -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 <https://www.gnu.org/licenses/>. */
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);
}
@@ -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),
@@ -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 <https://www.gnu.org/licenses/>. */
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<? extends AbstractWithingsActivitySample> 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.
*
* <p>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.
*
* <p>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.
*
* <p>Screen ID <-> slot mapping is defined in {@link WithingsScreenId#getScanwatchSlot(int)}.
*
* <p>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<String> defaultScreens = Arrays.asList(
getContext().getResources().getStringArray(R.array.pref_withings_scanwatch_screens_default));
final String screensPref = prefs.getString(PREF_SCREENS_SORTABLE, null);
final List<String> 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;
}
}
}
@@ -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;
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<? extends nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample> 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<? extends Alarm> 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.
*
* <p>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<String> allActivityTypes = Arrays.asList(getContext().getResources().getStringArray(R.array.pref_withings_steel_activity_types_values));
final List<String> 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<String> 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();
}
}
@@ -16,763 +16,26 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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<? extends Alarm> 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<String> allActivityTypes = Arrays.asList(getContext().getResources().getStringArray(R.array.pref_withings_steel_activity_types_values));
final List<String> 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<String> 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<? extends AbstractWithingsActivitySample> createSampleProvider(GBDevice device, DaoSession session) {
return new WithingsSteelHRSampleProvider(device, session);
}
}
@@ -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 extends AbstractWithingsActivitySample> T mergeIfNecessary(
AbstractSampleProvider<T> 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<WithingsSteelHRActivitySample> samples = provider.getActivitySamples((int)timestamp - 500, (int)timestamp);
private static <T extends AbstractWithingsActivitySample> T getOverlappingSample(
AbstractSampleProvider<T> provider, long timestamp) {
List<T> 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 extends AbstractWithingsActivitySample> T doMerge(
AbstractSampleProvider<T> 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());
@@ -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() {
@@ -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 <https://www.gnu.org/licenses/>. */
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");
}
@@ -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();
}
@@ -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<ActivityEntry> activityEntries = new ArrayList<>();
private List<ActivityEntry> 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<WithingsSteelHRActivitySample> activitySamples = new ArrayList<>();
for (ActivityEntry activityEntry : activityEntries) {
convertToSampleAndAddToList(activitySamples, activityEntry);
}
for (ActivityEntry activityEntry : heartrateEntries) {
convertToSampleAndAddToList(activitySamples, activityEntry);
}
writeToDB(activitySamples);
}
private void writeToDB(List<WithingsSteelHRActivitySample> 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<? extends AbstractWithingsActivitySample> provider =
support.createSampleProvider(device, dbHandler.getDaoSession());
List<AbstractWithingsActivitySample> 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 <T extends AbstractWithingsActivitySample> void writeToDB(
AbstractSampleProvider<T> provider, List<AbstractWithingsActivitySample> activitySamples) {
provider.addGBActivitySamples((T[]) activitySamples.toArray(new AbstractWithingsActivitySample[0]));
}
private AbstractWithingsActivitySample convertToSample(
AbstractSampleProvider<? extends AbstractWithingsActivitySample> 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<WithingsSteelHRActivitySample> 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);
}
}
@@ -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);
}
@@ -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<Conversation> 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));
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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).
*
* <p>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<WithingsStructure> 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();
}
}
}
}
@@ -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 <T extends AbstractWithingsActivitySample> 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<T> provider =
(AbstractSampleProvider<T>) 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());
@@ -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 <https://www.gnu.org/licenses/>. */
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<WithingsStructure> 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);
}
}
}
}
@@ -16,13 +16,13 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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);
}
@@ -16,13 +16,13 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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);
}
@@ -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);
}
@@ -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);
@@ -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);
@@ -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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures;
import java.nio.ByteBuffer;
/**
* Encodes the long-press crown shortcut action (TLV type 0x09A1).
*
* <p>Wire format: 2-byte type (0x09A1) + 2-byte length (0x0001) + 1-byte action value.
*
* <p>Known action values:
* <pre>
* 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
* </pre>
*/
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;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures;
/**
* Known screen IDs used in the Withings protocol SET_SCREEN_LIST command (message type 1292).
*
* <p>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).
*
* <p><b>ScanWatch screen IDs</b> 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.
*
* <p><b>Steel HR screen IDs</b> 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;
}
}
}
@@ -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() {}
}
@@ -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;
}
@@ -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;
}
@@ -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<WithingsStructure> structures = dataStructureFactory.createStructuresFromRawData(rawStructureData);
@@ -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() {}
}
@@ -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<Short, IncomingMessageHandler> 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);
}
@@ -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;
}
@@ -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;
}
@@ -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<String, byte[]> appIconCache = new HashMap<>();
public NotificationRequestHandler(WithingsSteelHRDeviceSupport support) {
public NotificationRequestHandler(WithingsBaseDeviceSupport support) {
this.support = support;
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
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;
}
@@ -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<Integer, NotificationSpec> 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;
}
+80
View File
@@ -1834,6 +1834,86 @@
<item>other</item>
</string-array>
<!-- Withings ScanWatch screen list (drag-to-reorder) -->
<string-array name="pref_withings_scanwatch_screens">
<item>@string/withings_scanwatch_screen_date</item>
<item>@string/withings_scanwatch_screen_sleep</item>
<item>@string/withings_scanwatch_screen_ecg</item>
<item>@string/withings_scanwatch_screen_elevation</item>
<item>@string/withings_scanwatch_screen_heart_rate</item>
<item>@string/withings_scanwatch_screen_spo2</item>
<item>@string/withings_scanwatch_screen_calories</item>
<item>@string/withings_scanwatch_screen_settings</item>
<item>@string/withings_scanwatch_screen_workouts</item>
<item>@string/withings_scanwatch_screen_distance</item>
<item>@string/withings_scanwatch_screen_steps</item>
<item>@string/withings_scanwatch_screen_breathe</item>
<item>@string/withings_scanwatch_screen_clock</item>
</string-array>
<string-array name="pref_withings_scanwatch_screens_values">
<item>date</item>
<item>sleep</item>
<item>ecg</item>
<item>elevation</item>
<item>heart_rate</item>
<item>spo2</item>
<item>calories</item>
<item>settings</item>
<item>workouts</item>
<item>distance</item>
<item>steps</item>
<item>breathe</item>
<item>clock</item>
</string-array>
<!-- Default order: all 13 screens enabled in the confirmed app order -->
<string-array name="pref_withings_scanwatch_screens_default">
<item>date</item>
<item>sleep</item>
<item>ecg</item>
<item>elevation</item>
<item>heart_rate</item>
<item>spo2</item>
<item>calories</item>
<item>settings</item>
<item>workouts</item>
<item>distance</item>
<item>steps</item>
<item>breathe</item>
<item>clock</item>
</string-array>
<!-- Long-press crown shortcut options.
ECG (1) and SpO2 (2) require the user to have accepted the health terms & conditions
in the Withings app before they can be activated via BLE - see ShortcutAction. -->
<string-array name="pref_withings_scanwatch_shortcut_entries">
<item>@string/withings_scanwatch_shortcut_none</item>
<item>@string/withings_scanwatch_shortcut_ecg</item>
<item>@string/withings_scanwatch_shortcut_spo2</item>
<item>@string/withings_scanwatch_shortcut_workout_start</item>
<item>@string/withings_scanwatch_shortcut_workout_selection</item>
<item>@string/withings_scanwatch_shortcut_breathe</item>
<item>@string/withings_scanwatch_shortcut_stopwatch</item>
<item>@string/withings_scanwatch_shortcut_timer</item>
<item>@string/withings_scanwatch_shortcut_dnd</item>
<item>@string/withings_scanwatch_shortcut_quicklook</item>
</string-array>
<!-- Values are the string representations of the action bytes (0-9).
10 = FINDMYPHONE and 11 = FLASHLIGHT are intentionally omitted (unsupported). -->
<string-array name="pref_withings_scanwatch_shortcut_values">
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
<item>5</item>
<item>6</item>
<item>7</item>
<item>8</item>
<item>9</item>
</string-array>
<string-array name="pref_neo_display_items">
<item>@string/menuitem_pai</item>
<item>@string/menuitem_dnd</item>
+32
View File
@@ -3486,6 +3486,7 @@
<string name="devicetype_onemore_sonoflow" translatable="false">1MORE SonoFlow</string>
<string name="devicetype_sony_wena3" translatable="false">Sony Wena 3</string>
<string name="devicetype_withings_steel_hr" translatable="false">Withings Steel HR</string>
<string name="devicetype_withings_scanwatch" translatable="false">Withings Scanwatch</string>
<string name="pref_button_action_disabled">Disabled</string>
<string name="pref_media_play">Media Play</string>
<string name="pref_media_pause">Media Pause</string>
@@ -3736,6 +3737,37 @@
<string name="withings_calibration_text_activity_target">Finally align the activity hand to 100%. Please be aware that this hand only moves clockwise.</string>
<string name="withings_bt_calibration_previous">Previous</string>
<string name="withings_bt_calibration_next">Next</string>
<string name="withings_scanwatch_pref_screens_title">Watch screens</string>
<string name="withings_scanwatch_pref_screens_summary">Choose which screens appear on the watch and drag to reorder them</string>
<string name="withings_scanwatch_screen_date">Date</string>
<string name="withings_scanwatch_screen_sleep">Sleep</string>
<string name="withings_scanwatch_screen_ecg">ECG</string>
<string name="withings_scanwatch_screen_elevation">Elevation</string>
<string name="withings_scanwatch_screen_heart_rate">Heart rate</string>
<string name="withings_scanwatch_screen_spo2">SpO2 / Blood oxygen</string>
<string name="withings_scanwatch_screen_calories">Calories</string>
<string name="withings_scanwatch_screen_settings">Settings</string>
<string name="withings_scanwatch_screen_workouts">Workouts</string>
<string name="withings_scanwatch_screen_distance">Distance</string>
<string name="withings_scanwatch_screen_steps">Steps</string>
<string name="withings_scanwatch_screen_breathe">Breathe</string>
<string name="withings_scanwatch_screen_clock">Clock</string>
<!-- Shortcut (long-press crown) settings -->
<string name="withings_scanwatch_pref_shortcut_title">Long-press crown shortcut</string>
<string name="withings_scanwatch_pref_shortcut_summary">Action launched by holding the crown button</string>
<string name="withings_scanwatch_shortcut_none">None</string>
<string name="withings_scanwatch_shortcut_ecg">ECG measurement</string>
<string name="withings_scanwatch_shortcut_spo2">SpO2 / Blood oxygen measurement</string>
<string name="withings_scanwatch_shortcut_workout_start">Start workout</string>
<string name="withings_scanwatch_shortcut_workout_selection">Workout selection</string>
<string name="withings_scanwatch_shortcut_breathe">Breathe</string>
<string name="withings_scanwatch_shortcut_stopwatch">Stopwatch</string>
<string name="withings_scanwatch_shortcut_timer">Timer</string>
<string name="withings_scanwatch_shortcut_dnd">Do not disturb</string>
<string name="withings_scanwatch_shortcut_quicklook">Quick look</string>
<string name="withings_scanwatch_shortcut_findmyphone">Find my phone</string>
<string name="withings_scanwatch_shortcut_flashlight">Flashlight</string>
<string name="drag_handle">drag handle</string>
<string name="find_my_phone_found_it">FOUND IT</string>
<string name="pref_activity_full_sync_trigger_summary">Trigger a full sync of all activity data</string>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<com.mobeta.android.dslv.DragSortListPreference
android:icon="@drawable/ic_activity_unknown_small"
android:defaultValue="@array/pref_withings_steel_activity_types_default"
android:dialogTitle="@string/mi5_prefs_workout_activity_types"
android:entries="@array/pref_withings_steel_activity_types"
android:entryValues="@array/pref_withings_steel_activity_types_values"
android:key="workout_activity_types_sortable"
android:persistent="true"
android:summary="@string/mi5_prefs_workout_activity_types_summary"
android:title="@string/mi5_prefs_workout_activity_types" />
<Preference
android:title="@string/qhybrid_title_calibration"
android:icon="@drawable/ic_sensor_calibration"
android:summary="@string/qhybrid_summary_calibration"
android:key="hands_calibration">
<intent
android:targetPackage="@string/applicationId"
android:targetClass="nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.WithingsCalibrationActivity" />
</Preference>
<!-- Watch screen configuration: drag to reorder, uncheck to disable -->
<com.mobeta.android.dslv.DragSortListPreference
android:icon="@drawable/ic_watchface"
android:defaultValue="@array/pref_withings_scanwatch_screens_default"
android:dialogTitle="@string/withings_scanwatch_pref_screens_title"
android:entries="@array/pref_withings_scanwatch_screens"
android:entryValues="@array/pref_withings_scanwatch_screens_values"
android:key="withings_scanwatch_screens_sortable"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_screens_summary"
android:title="@string/withings_scanwatch_pref_screens_title" />
<!-- Long-press crown shortcut -->
<ListPreference
android:defaultValue="0"
android:entries="@array/pref_withings_scanwatch_shortcut_entries"
android:entryValues="@array/pref_withings_scanwatch_shortcut_values"
android:key="withings_scanwatch_shortcut_action"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_shortcut_summary"
android:title="@string/withings_scanwatch_pref_shortcut_title" />
</androidx.preference.PreferenceScreen>
@@ -19,6 +19,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
final Map<String, DeviceType> 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