feat(withings,scanwatch): Screen order

This commit is contained in:
d3vv3
2026-03-28 13:15:06 +01:00
parent bc3c691095
commit 76370f9c46
15 changed files with 883 additions and 140 deletions
@@ -22,7 +22,10 @@ import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
@@ -41,6 +44,9 @@ public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettin
static final String PREF_AUTO_BRIGHTNESS = "withings_scanwatch_auto_brightness";
static final String PREF_BRIGHTNESS_LEVEL = "withings_scanwatch_brightness_level";
static final String PREF_MOVE_HANDS = "withings_scanwatch_move_hands";
static final String PREF_HR_ALERT_MODE = "withings_scanwatch_hr_alert_mode";
static final String PREF_HR_ALERT_LOW = "withings_scanwatch_hr_alert_low";
static final String PREF_HR_ALERT_HIGH = "withings_scanwatch_hr_alert_high";
@Override
public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
@@ -53,6 +59,38 @@ public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettin
handler.addPreferenceHandlerFor(PREF_QUICKLOOK);
handler.addPreferenceHandlerFor(PREF_AUTO_BRIGHTNESS);
handler.addPreferenceHandlerFor(PREF_MOVE_HANDS);
handler.addPreferenceHandlerFor(PREF_HR_ALERT_MODE);
handler.addPreferenceHandlerFor(PREF_HR_ALERT_LOW);
handler.addPreferenceHandlerFor(PREF_HR_ALERT_HIGH);
// "Watch face (Date)" must always be pinned at position 0.
// Intercept changes to the screens pref and normalise the stored value so that
// "date" is always present and always first before it is written to SharedPreferences.
// The service layer also enforces this at send time, but correcting the stored value
// here keeps the UI consistent with what will actually be sent to the watch.
handler.addPreferenceHandlerFor(PREF_SCREENS_SORTABLE, (pref, newValue) -> {
if (!(newValue instanceof String)) {
return true; // nothing to normalise; let the default handler proceed
}
final String raw = (String) newValue;
final List<String> screens = new ArrayList<>();
if (!raw.isEmpty()) {
screens.addAll(Arrays.asList(raw.split(",")));
}
// Move "date" to position 0 (or add it if it was removed)
screens.remove("date");
screens.add(0, "date");
final String normalised = android.text.TextUtils.join(",", screens);
if (!normalised.equals(raw)) {
// Persist the corrected value ourselves and reject the original so that the
// preference does not store the out-of-order / missing-date value.
if (pref.getSharedPreferences() != null) {
pref.getSharedPreferences().edit().putString(PREF_SCREENS_SORTABLE, normalised).apply();
}
return false;
}
return true;
});
// Brightness level is only meaningful when auto-brightness is OFF (inverse dependency)
final EditTextPreference brightnessPref = handler.findPreference(PREF_BRIGHTNESS_LEVEL);
@@ -87,6 +125,27 @@ public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettin
if (respiratoryPref != null) {
respiratoryPref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
}
// HR alert mode summary
final ListPreference hrAlertModePref = handler.findPreference(PREF_HR_ALERT_MODE);
if (hrAlertModePref != null) {
hrAlertModePref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
}
// Show threshold prefs only when mode is "custom"
final boolean isCustom = "custom".equals(prefs.getString(PREF_HR_ALERT_MODE, "off"));
final ListPreference hrLowPref = handler.findPreference(PREF_HR_ALERT_LOW);
if (hrLowPref != null) {
hrLowPref.setEnabled(isCustom);
hrLowPref.setVisible(isCustom);
hrLowPref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
}
final ListPreference hrHighPref = handler.findPreference(PREF_HR_ALERT_HIGH);
if (hrHighPref != null) {
hrHighPref.setEnabled(isCustom);
hrHighPref.setVisible(isCustom);
hrHighPref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
}
}
@Override
@@ -99,6 +158,22 @@ public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettin
brightnessPref.setEnabled(!autoOn);
}
}
if (PREF_HR_ALERT_MODE.equals(preference.getKey())) {
// The new value has already been persisted; read it back to update visibility
final String newMode = preference.getSharedPreferences().getString(PREF_HR_ALERT_MODE, "off");
final boolean isCustom = "custom".equals(newMode);
final ListPreference hrLowPref = handler.findPreference(PREF_HR_ALERT_LOW);
if (hrLowPref != null) {
hrLowPref.setEnabled(isCustom);
hrLowPref.setVisible(isCustom);
}
final ListPreference hrHighPref = handler.findPreference(PREF_HR_ALERT_HIGH);
if (hrHighPref != null) {
hrHighPref.setEnabled(isCustom);
hrHighPref.setVisible(isCustom);
}
}
}
@Override
@@ -21,21 +21,26 @@ import android.content.SharedPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.List;
import java.util.Objects;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
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.model.ActivitySample;
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.conversation.GetUserHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetGlanceHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetLuminosityHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetMoveHandsHandler;
@@ -44,6 +49,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.FeatureTagDeprecated;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.FeatureTagsUserId;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.GlanceStatus;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.HrAlertThreshold;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LocalNotification;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LuminosityLevel;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings;
@@ -105,8 +111,45 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
/** Whether tracker move-hands (move hands to 10:10 when screen turns on) is enabled. */
static final String PREF_MOVE_HANDS = "withings_scanwatch_move_hands";
/**
* Resting heart rate alert mode: {@code "off"}, {@code "automatic"}, or {@code "custom"}.
* <ul>
* <li>{@code "off"} - both high and low resting HR alerts are disabled.</li>
* <li>{@code "automatic"} - thresholds are computed from Gadgetbridge's own HR history.</li>
* <li>{@code "custom"} - user-specified thresholds via {@link #PREF_HR_ALERT_LOW} /
* {@link #PREF_HR_ALERT_HIGH}.</li>
* </ul>
*/
static final String PREF_HR_ALERT_MODE = "withings_scanwatch_hr_alert_mode";
/**
* Low resting HR alert threshold in BPM (integer string), used when mode is {@code "custom"}.
* Clamped to [30, 200] before sending to device.
*/
static final String PREF_HR_ALERT_LOW = "withings_scanwatch_hr_alert_low";
/**
* High resting HR alert threshold in BPM (integer string), used when mode is {@code "custom"}.
* Clamped to [30, 200] before sending to device.
*/
static final String PREF_HR_ALERT_HIGH = "withings_scanwatch_hr_alert_high";
/**
* Number of days of HR history used for automatic threshold calculation.
* @see #computeAutoHrThresholds()
*/
private static final int AUTO_HR_HISTORY_DAYS = 30;
/** Minimum valid BPM value accepted by the watch. */
private static final int HR_BPM_MIN = 30;
/** Maximum valid BPM value accepted by the watch. */
private static final int HR_BPM_MAX = 200;
@Override
protected void addExtraSyncCommands() {
// Fetch the watch's Withings userId first so it is stored in prefs before any command
// that requires it (screen list, HR alert thresholds, feature tags).
addSimpleConversationToQueue(
new WithingsMessage(WithingsMessageType.GET_USER),
new GetUserHandler(this)
);
addSimpleConversationToQueue(
new WithingsMessage(WithingsMessageType.GET_SHORTCUT),
new GetShortcutHandler(this)
@@ -128,15 +171,22 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
new GetWearPosHandler(this)
);
// Re-push feature tags and local notifications on every sync so the watch
// retains the correct state after a Bluetooth reconnect or reboot.
// Re-push feature tags, HR alert thresholds, and local notifications on every sync so
// the watch retains the correct state after a Bluetooth reconnect or reboot.
// HR alert commands are only sent if the user has explicitly enabled the feature in settings.
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean ecg = prefs.getBoolean(PREF_ECG_ENABLED, false);
final String respScan = prefs.getString(PREF_RESPIRATORY_SCAN, "off");
final boolean afibDay = prefs.getBoolean(PREF_AFIB_DAY_ENABLED, false);
final boolean afibNight = prefs.getBoolean(PREF_AFIB_NIGHT_ENABLED, false);
addFeatureTagsCommand(ecg, respScan, afibDay, afibNight);
addLocalNotificationsCommand(afibDay, afibNight);
final String hrMode = prefs.getString(PREF_HR_ALERT_MODE, "off");
final boolean hrAlertsOn = !"off".equals(hrMode);
addFeatureTagsCommand(ecg, respScan, afibDay, afibNight, hrAlertsOn);
if (hrAlertsOn) {
addHrAlertCommand(hrMode, prefs);
}
addLocalNotificationsCommand(afibDay, afibNight, hrAlertsOn);
}
@Override
@@ -171,9 +221,27 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
final String respScan = prefs.getString(PREF_RESPIRATORY_SCAN, "off");
final boolean afibDay = prefs.getBoolean(PREF_AFIB_DAY_ENABLED, false);
final boolean afibNight = prefs.getBoolean(PREF_AFIB_NIGHT_ENABLED, false);
final String hrMode = prefs.getString(PREF_HR_ALERT_MODE, "off");
final boolean hrAlertsOn = !"off".equals(hrMode);
clearQueue();
addFeatureTagsCommand(ecg, respScan, afibDay, afibNight);
addLocalNotificationsCommand(afibDay, afibNight);
addFeatureTagsCommand(ecg, respScan, afibDay, afibNight, hrAlertsOn);
addLocalNotificationsCommand(afibDay, afibNight, hrAlertsOn);
sendQueue();
return true;
}
if (PREF_HR_ALERT_MODE.equals(config) || PREF_HR_ALERT_LOW.equals(config)
|| PREF_HR_ALERT_HIGH.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean ecg = prefs.getBoolean(PREF_ECG_ENABLED, false);
final String respScan = prefs.getString(PREF_RESPIRATORY_SCAN, "off");
final boolean afibDay = prefs.getBoolean(PREF_AFIB_DAY_ENABLED, false);
final boolean afibNight = prefs.getBoolean(PREF_AFIB_NIGHT_ENABLED, false);
final String hrMode = prefs.getString(PREF_HR_ALERT_MODE, "off");
final boolean hrAlertsOn = !"off".equals(hrMode);
clearQueue();
addFeatureTagsCommand(ecg, respScan, afibDay, afibNight, hrAlertsOn);
addHrAlertCommand(hrMode, prefs);
addLocalNotificationsCommand(afibDay, afibNight, hrAlertsOn);
sendQueue();
return true;
}
@@ -246,50 +314,81 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
/**
* Queues a {@code CMD_FEATURE_TAGS_SET_DEPRECATED_V2} (0x0987) message activating the
* feature tags required for ECG, respiratory scan, and/or AFib.
* feature tags required for ECG, respiratory scan, AFib, notifications, and/or resting HR alerts.
*
* <p>Tag set from HCI captures:
* <p>Tag set derived from HCI captures (corrected mapping):
* <ul>
* <li>ECG on: tags 0x0004, 0x000F, 0x0035, 0x0058</li>
* <li>Respiratory automatic: tag 0x0005 + 0x000F, 0x0035, 0x0058</li>
* <li>Respiratory always: tags 0x000E, 0x0011 + 0x000F, 0x0035, 0x0058</li>
* <li>AFib daytime: tags 0x0009, 0x000A</li>
* <li>AFib night: tag 0x000B</li>
* <li>ECG on: tag 0x0004</li>
* <li>Respiratory off: tag 0x000A only (base)</li>
* <li>Respiratory automatic: tags 0x0009 (start=now, end=noon-next-day), 0x000A, 0x000B</li>
* <li>Respiratory always-on: tags 0x0009 (start=0, end=0), 0x000A</li>
* <li>AFib on: tags 0x000E, 0x0011</li>
* <li>HR alerts on: tag 0x0016 (LOW_HR)</li>
* </ul>
* 0x0035 and 0x0058 are sent whenever any health feature is active (purpose unknown).
* 0x000F (TAG_ECG_MEAS) is shared between ECG and respiratory scan; sent once even if both on.
* 0x000F (SpO2 measurement), 0x0013 (notifications), 0x0035 and 0x0058 are sent whenever
* any health feature is active. The ScanWatch keeps these tags on permanently once enabled.
*/
private void addFeatureTagsCommand(final boolean ecgEnabled, final String respiratoryScan,
final boolean afibDayEnabled, final boolean afibNightEnabled) {
final boolean afibDayEnabled, final boolean afibNightEnabled,
final boolean hrAlertsOn) {
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_FEATURE_TAGS_DEPRECATED);
msg.addDataStructure(new FeatureTagsUserId()); // userId = 0
// TODO: replace hardcoded userId with a user-configurable setting
final SharedPreferences featurePrefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final int featureUserId = featurePrefs.getInt(WithingsBaseDeviceSupport.PREF_WITHINGS_USER_ID, 0x01f53022);
msg.addDataStructure(new FeatureTagsUserId(featureUserId));
final boolean respAutomatic = "automatic".equals(respiratoryScan);
final boolean respAlways = "always".equals(respiratoryScan);
final boolean respActive = respAutomatic || respAlways;
final boolean anyFeatureOn = ecgEnabled || respAutomatic || respAlways
|| afibDayEnabled || afibNightEnabled || hrAlertsOn;
if (ecgEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_ECG_TERMS));
}
// Respiratory scan tags
if (respAutomatic) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_SPO2_SLEEP));
// Automatic: TAG_RESP_SCAN with start=now, end=noon-next-day
final int now = (int) (System.currentTimeMillis() / 1000);
final Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_YEAR, 1);
cal.set(Calendar.HOUR_OF_DAY, 12);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
final int noonNextDay = (int) (cal.getTimeInMillis() / 1000);
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_RESP_SCAN, now, noonNextDay));
} else if (respAlways) {
// Always-on: TAG_RESP_SCAN with start=0, end=0
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_RESP_SCAN));
}
if (respAlways) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_RESP_ALWAYS));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0011));
// Off: TAG_RESP_SCAN (0x0009) is absent
// TAG_RESP_BASE (0x000A) is always present (sent in all modes including off)
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_RESP_BASE));
if (respAutomatic) {
// TAG_RESP_AUTO (0x000B) is only present in automatic mode
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_RESP_AUTO));
}
if (afibDayEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_WINDOW));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_EXTRA));
// AFib detection: tags 0x000E + 0x0011
if (afibDayEnabled || afibNightEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB));
}
if (afibNightEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_NIGHT));
// SpO2 measurement (0x000F) - always present when any health feature is active
if (anyFeatureOn) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_SPO2_MEAS));
}
if (ecgEnabled || respActive) {
// TAG_ECG_MEAS (0x000F) enables ECG/respiratory measurements; send once even if both on
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_ECG_MEAS));
if (afibDayEnabled || afibNightEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_2));
}
if (ecgEnabled || respActive || afibDayEnabled || afibNightEnabled) {
// Notifications (0x0013) - always present when any health feature is active
if (anyFeatureOn) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_NOTIFICATIONS));
}
if (hrAlertsOn) {
// TAG_LOW_HR (0x0016) is sent when resting HR alerts are enabled.
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_LOW_HR));
}
if (anyFeatureOn) {
// Baseline tags always present when any health feature is active
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0035));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0058));
@@ -305,29 +404,144 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
* <p>All five slots must always be sent together. Slot mapping from HCI captures:
* <ol>
* <li>PPG_AFIB - enabled when AFib daytime is on</li>
* <li>HIGH_HR - enabled when AFib daytime is on (high HR alert, tied to AFib/ECG state)</li>
* <li>LOW_HR - enabled when AFib daytime is on (low HR alert, tied to AFib/ECG state)</li>
* <li>HIGH_HR - enabled when resting HR alerts are on (independent of AFib)</li>
* <li>LOW_HR - enabled when resting HR alerts are on (independent of AFib)</li>
* <li>SLOT_4 - purpose unknown; always disabled</li>
* <li>PPG_AFIB_NIGHT - enabled when AFib night is on</li>
* </ol>
*
* <p>Note: HIGH_HR (slot 2) and LOW_HR (slot 3) are independent of AFib.
* The resting HR alert enable/disable state is controlled separately via
* {@link #addHrAlertCommand}.
*/
private void addLocalNotificationsCommand(final boolean afibDayEnabled,
final boolean afibNightEnabled) {
final boolean afibNightEnabled,
final boolean hrAlertsOn) {
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_LOCAL_NOTIFICATIONS);
// Order must match the official app: AFIB_DAY(1), SLOT_4(4), AFIB_NIGHT(5), HIGH_HR(2), LOW_HR(3)
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_PPG_AFIB,
afibDayEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_HIGH_HR,
afibDayEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_LOW_HR,
afibDayEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_SLOT_4,
LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_PPG_AFIB_NIGHT,
afibNightEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_HIGH_HR,
hrAlertsOn ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_LOW_HR,
hrAlertsOn ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new EndOfTransmission());
addSimpleConversationToQueue(msg);
}
/**
* Queues a {@code CMD_SET_HR_ALERT_THRESHOLDS} (0x098e) message configuring the resting
* heart rate alert thresholds and their enabled/disabled state.
*
* <p>The message always contains both a LOW and a HIGH threshold entry. When the mode is
* {@code "off"} both entries are sent with {@code enabled=0} but the threshold values are
* preserved from the previous setting (or fall back to the default custom values) so that the
* watch can restore them if alerts are re-enabled.
*
* <p>The watch echoes the command type (0x098E) back as the response when the payload is
* accepted, so the default {@link ExpectedResponse#SIMPLE} is used. Earlier versions of this
* code used {@link ExpectedResponse#NONE} as a workaround for a stall caused by the watch
* returning {@code CMD_ERROR (0x0100)} when it received a malformed payload; that workaround
* is no longer necessary now that the payload is correct.
*
* @param hrMode {@code "off"}, {@code "automatic"}, or {@code "custom"}
* @param prefs device-specific {@link SharedPreferences} from which to read custom thresholds
*/
private void addHrAlertCommand(final String hrMode, final SharedPreferences prefs) {
final byte enabled = "off".equals(hrMode) ? HrAlertThreshold.DISABLED : HrAlertThreshold.ENABLED;
final int lowBpm;
final int highBpm;
if ("automatic".equals(hrMode)) {
final int[] auto = computeAutoHrThresholds();
lowBpm = auto[0];
highBpm = auto[1];
} else {
// "custom" or "off" - read persisted values (defaults: low=40, high=100)
int rawLow = 40;
int rawHigh = 100;
try {
rawLow = Integer.parseInt(prefs.getString(PREF_HR_ALERT_LOW, "40"));
rawHigh = Integer.parseInt(prefs.getString(PREF_HR_ALERT_HIGH, "100"));
} catch (NumberFormatException e) {
logger.warn("Invalid HR alert threshold pref: low='{}' high='{}'",
prefs.getString(PREF_HR_ALERT_LOW, "40"),
prefs.getString(PREF_HR_ALERT_HIGH, "100"));
}
lowBpm = Math.max(HR_BPM_MIN, Math.min(HR_BPM_MAX, rawLow));
highBpm = Math.max(HR_BPM_MIN, Math.min(HR_BPM_MAX, rawHigh));
}
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_HR_ALERT_THRESHOLDS);
// Use the Withings userId stored during the GET_USER handshake. The watch rejects userId=0
// with CMD_ERROR(-4); if not yet stored, fall back to hardcoded userId.
// TODO: replace hardcoded userId with a user-configurable setting
final int userId = prefs.getInt(WithingsBaseDeviceSupport.PREF_WITHINGS_USER_ID, 0x01f53022);
msg.addDataStructure(new FeatureTagsUserId(userId));
msg.addDataStructure(new HrAlertThreshold(HrAlertThreshold.DIRECTION_LOW, enabled, (byte) lowBpm));
msg.addDataStructure(new HrAlertThreshold(HrAlertThreshold.DIRECTION_HIGH, enabled, (byte) highBpm));
msg.addDataStructure(new EndOfTransmission());
addSimpleConversationToQueue(msg);
}
/**
* Computes automatic resting heart rate alert thresholds from Gadgetbridge's own HR history.
*
* <p>Queries the last {@value #AUTO_HR_HISTORY_DAYS} days of activity samples, takes the
* average of all valid (measured) HR readings, then sets:
* <ul>
* <li>Low threshold = average - 20 bpm (floor {@value #HR_BPM_MIN})</li>
* <li>High threshold = average + 30 bpm (ceiling {@value #HR_BPM_MAX})</li>
* </ul>
* If no HR history is available the defaults 40 / 100 bpm are returned.
*
* @return int[2] where [0] = low threshold and [1] = high threshold, in BPM
*/
private int[] computeAutoHrThresholds() {
final int defaultLow = 40;
final int defaultHigh = 100;
final Calendar cal = Calendar.getInstance();
final int tsTo = (int) (cal.getTimeInMillis() / 1000);
cal.add(Calendar.DAY_OF_YEAR, -AUTO_HR_HISTORY_DAYS);
final int tsFrom = (int) (cal.getTimeInMillis() / 1000);
try (DBHandler dbHandler = GBApplication.acquireDB()) {
final AbstractSampleProvider<? extends AbstractWithingsActivitySample> provider =
createSampleProvider(gbDevice, dbHandler.getDaoSession());
final List<? extends AbstractWithingsActivitySample> samples =
provider.getAllActivitySamples(tsFrom, tsTo);
long hrSum = 0;
int hrCount = 0;
for (final AbstractWithingsActivitySample sample : samples) {
final int hr = sample.getHeartRate();
if (hr > ActivitySample.NOT_MEASURED && hr > 0) {
hrSum += hr;
hrCount++;
}
}
if (hrCount == 0) {
logger.debug("No HR history for auto thresholds, using defaults {}/{}", defaultLow, defaultHigh);
return new int[]{defaultLow, defaultHigh};
}
final int avg = (int) (hrSum / hrCount);
logger.debug("Auto HR thresholds: avg={}bpm from {} samples", avg, hrCount);
final int low = Math.max(HR_BPM_MIN, avg - 20);
final int high = Math.min(HR_BPM_MAX, avg + 30);
logger.debug("Auto HR thresholds computed: low={}bpm, high={}bpm", low, high);
return new int[]{low, high};
} catch (Exception e) {
logger.warn("Could not read HR history for auto thresholds: {}", e.getMessage());
return new int[]{defaultLow, defaultHigh};
}
}
/**
* Builds a SET_WORKOUT_SCREEN message for the ScanWatch with correct mode/flags and
* two icon sizes (20x20 at idx=0, 28x28 at idx=1) as required by the ScanWatch protocol.
@@ -385,19 +599,31 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
* 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>Display order on the watch is determined by the <em>sequence</em> of entries in the
* SET_SCREEN_LIST packet - entries are sent in the same order the user arranged them. Each
* screen also carries a fixed {@code idOnDevice} byte (confirmed from BLE captures) that is
* not a position index; it is a fixed property of each screen.
*
* <p>Screen ID <-> slot mapping is defined in {@link WithingsScreenId#getScanwatchSlot(int)}.
* <p>Screen ID <-> idOnDevice mapping is defined in
* {@link WithingsScreenId#getScanwatchIdOnDevice(int)}.
*
* <p>This method is a no-op if the current screen list matches what was last successfully sent
* to the watch.
* <p><b>Pinning:</b> The {@code "date"} screen (Watch face / Date) is always enforced at
* position 0 before sending, regardless of what is stored in the preference. If it is missing
* from the stored list it is re-inserted; if it is present but not first it is moved to the
* front. The UI-layer customizer also normalises the stored value on every change, so the two
* layers stay in sync.
*
* <p>This method is a no-op if the current screen list (after pinning) matches what was last
* successfully sent to the watch.
*/
@Override
protected void addScreenListCommands() {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final int userId = prefs.getInt(WithingsBaseDeviceSupport.PREF_WITHINGS_USER_ID, 0);
if (userId <= 0) {
logger.warn("Skipping SET_SCREEN_LIST: missing confirmed watch userId (value={})", userId);
return;
}
final List<String> defaultScreens = Arrays.asList(
getContext().getResources().getStringArray(R.array.pref_withings_scanwatch_screens_default));
@@ -405,11 +631,18 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
final String screensPref = prefs.getString(PREF_SCREENS_SORTABLE, null);
final List<String> enabledScreens;
if (screensPref == null || screensPref.isEmpty()) {
enabledScreens = defaultScreens;
enabledScreens = new ArrayList<>(defaultScreens);
} else {
enabledScreens = Arrays.asList(screensPref.split(","));
enabledScreens = new ArrayList<>(Arrays.asList(screensPref.split(",")));
}
// "Watch face (Date)" is always pinned at position 0. Enforce this at send time
// regardless of what the UI preference contains, so the watch is always consistent
// even if the user somehow bypassed the UI-level constraint.
final String PINNED_SCREEN = "date";
enabledScreens.remove(PINNED_SCREEN);
enabledScreens.add(0, PINNED_SCREEN);
// 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);
@@ -419,27 +652,66 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
return;
}
Message message = new WithingsMessage(WithingsMessageType.SET_SCREEN_LIST);
final List<ScreenSettings> screenEntries = new ArrayList<>();
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);
final byte idOnDevice = WithingsScreenId.getScanwatchIdOnDevice(screenId);
if (idOnDevice < 0) {
logger.warn("No idOnDevice for screen key '{}' (id=0x{:04x}), skipping", screenKey, screenId);
continue;
}
message.addDataStructure(buildScreen(screenId, slot));
screenEntries.add(buildScanwatchScreen(screenId, idOnDevice, userId));
}
if (screenEntries.isEmpty()) {
logger.warn("No valid ScanWatch screens to send, skipping SET_SCREEN_LIST");
return;
}
// Official app sends CMD_SET_SCREEN_LIST in two logical messages for long lists:
// first up to 8 entries (without EOT), then remaining entries with EOT.
// Matching this framing avoids very large single-message payloads that can reboot the watch.
final int maxEntriesPerMessage = 8;
for (int i = 0; i < screenEntries.size(); i += maxEntriesPerMessage) {
final int chunkEnd = Math.min(i + maxEntriesPerMessage, screenEntries.size());
final boolean isFinalChunk = chunkEnd >= screenEntries.size();
final Message message = isFinalChunk
? new WithingsMessage(WithingsMessageType.SET_SCREEN_LIST)
: new WithingsMessage(WithingsMessageType.SET_SCREEN_LIST, ExpectedResponse.NONE);
for (int j = i; j < chunkEnd; j++) {
message.addDataStructure(screenEntries.get(j));
}
if (isFinalChunk) {
message.addDataStructure(new EndOfTransmission());
}
addSimpleConversationToQueue(message);
}
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();
}
/**
* Builds a {@link ScreenSettings} entry for the ScanWatch with the stored Withings userId.
*
* <p>The ScanWatch reboots if it receives a {@code CMD_SCREEN_LIST_SET} (0x050C) packet
* containing {@link ScreenSettings} entries with {@code userId = 0}. The official app always
* sends the real Withings account ID; we read it from prefs (stored by {@link GetUserHandler}
* during the handshake).
*/
private ScreenSettings buildScanwatchScreen(final int screenId, final byte idOnDevice, final int userId) {
final ScreenSettings settings = new ScreenSettings();
settings.setId(screenId);
settings.setIdOnDevice(idOnDevice);
settings.setUserId(userId);
settings.setScreenType(WithingsScreenId.getScanwatchScreenType(screenId));
return settings;
}
/**
* Maps a screen preference value key (as stored in the DragSortListPreference) to the
* corresponding {@link WithingsScreenId} constant.
@@ -124,6 +124,11 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
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";
/**
* Device-specific SharedPreferences key for the userId sent to the watch in SET_USER.
* Used by ScreenSettings and FeatureTagsUserId to ensure a consistent, non-zero userId.
*/
public static final String PREF_WITHINGS_USER_ID = "withings_user_id";
private final MessageBuilder messageBuilder;
private LiveWorkoutHandler liveWorkoutHandler;
private ActivitySampleHandler activitySampleHandler;
@@ -284,8 +289,15 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
if (shoudSync()) {
logger.debug("Doing full sync...");
final User user = getUser();
// Store the userId we're about to send to the watch so that all subsequent
// commands built in addExtraSyncCommands() (ScreenSettings, FeatureTagsUserId, etc.)
// can read a consistent, non-zero userId from prefs.
GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).edit()
.putInt(PREF_WITHINGS_USER_ID, user.getUserID())
.apply();
WithingsMessage message = new WithingsMessage(WithingsMessageType.SET_USER);
message.addDataStructure(getUser());
message.addDataStructure(user);
// 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());
@@ -0,0 +1,64 @@
/* 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 nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.User;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
/**
* Handles the response to {@code GET_USER} (cmd 0x0503).
*
* <p>The userId is pre-populated in device prefs by {@code WithingsBaseDeviceSupport.doSync()}
* before any commands are built, so all queued commands use the correct userId immediately.
* This handler updates the stored value with whatever the watch echoes back (which should be
* the same value we sent in SET_USER).
*/
public class GetUserHandler extends AbstractResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetUserHandler.class);
public GetUserHandler(WithingsBaseDeviceSupport support) {
super(support);
}
@Override
public void handleResponse(Message response) {
final User user = response.getStructureByType(User.class);
if (user == null) {
logger.warn("GetUserHandler: no User structure in response");
return;
}
final int userId = user.getUserID();
if (userId == 0) {
logger.warn("GetUserHandler: received userId=0, ignoring");
return;
}
logger.info("GetUserHandler: confirmed watch userId=0x{}", Integer.toHexString(userId));
final SharedPreferences prefs =
GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
prefs.edit()
.putInt(WithingsBaseDeviceSupport.PREF_WITHINGS_USER_ID, userId)
.apply();
}
}
@@ -158,6 +158,9 @@ public class DataStructureFactory {
case WithingsStructureType.TRACKER_WEAR_POS:
structure = new TrackerWearPos();
break;
case WithingsStructureType.USER:
structure = new User();
break;
default:
structure = null;
logger.info("Received yet unknown structure type: " + structureTypeFromResponse);
@@ -31,14 +31,16 @@ import java.nio.ByteBuffer;
* <p>Known feature tag IDs observed from HCI captures:
* <ul>
* <li>{@link #TAG_ECG_TERMS} (0x0004) - ECG terms &amp; conditions accepted</li>
* <li>{@link #TAG_SPO2_SLEEP} (0x0005) - Respiratory scan automatic/sleep mode</li>
* <li>{@link #TAG_AFIB_WINDOW} (0x0009) - AFib detection time window (uses timestamps)</li>
* <li>{@link #TAG_AFIB_EXTRA} (0x000A) - AFib detection (always active)</li>
* <li>{@link #TAG_AFIB_NIGHT} (0x000B) - Night-time AFib detection</li>
* <li>{@link #TAG_RESP_ALWAYS} (0x000E) - Respiratory scan always-on mode</li>
* <li>{@link #TAG_ECG_MEAS} (0x000F) - ECG/respiratory measurement enabled</li>
* <li>{@link #TAG_0x0011} (0x0011) - Companion to TAG_RESP_ALWAYS in always-on mode</li>
* <li>{@link #TAG_IRREGULAR_HR} (0x0013) - Irregular heart rate detection</li>
* <li>{@link #TAG_SPO2_SLEEP} (0x0005) - Transient; appears briefly during ECG enable flow</li>
* <li>{@link #TAG_RESP_SCAN} (0x0009) - Respiratory scan scheduling: absent=off, start/end=0 -> always-on,
* start=now/end=noon-next-day -> automatic</li>
* <li>{@link #TAG_RESP_BASE} (0x000A) - Respiratory scan base (present whenever resp feature is activated,
* including off state once enabled)</li>
* <li>{@link #TAG_RESP_AUTO} (0x000B) - Respiratory scan automatic LED scheduling (present only in automatic mode)</li>
* <li>{@link #TAG_AFIB} (0x000E) - AFib detection part 1</li>
* <li>{@link #TAG_SPO2_MEAS} (0x000F) - SpO2 / oxygen saturation measurement enabled</li>
* <li>{@link #TAG_AFIB_2} (0x0011) - AFib detection part 2 (companion to TAG_AFIB)</li>
* <li>{@link #TAG_NOTIFICATIONS}(0x0013) - On-watch notifications enabled</li>
* <li>{@link #TAG_HIGH_HR} (0x0014) - High heart rate notification</li>
* <li>{@link #TAG_LOW_HR} (0x0016) - Low heart rate notification</li>
* <li>{@link #TAG_0x0035} (0x0035) - Present when any health feature is active (purpose unknown)</li>
@@ -51,22 +53,29 @@ public class FeatureTagDeprecated extends WithingsStructure {
/** ECG terms &amp; conditions accepted by the user. */
public static final short TAG_ECG_TERMS = 0x0004;
/** Respiratory scan automatic mode (scans some nights). */
/** Transient tag; appears briefly during ECG enable flow (purpose not fully known). */
public static final short TAG_SPO2_SLEEP = 0x0005;
/** AFib detection time window (populated with actual timestamps). */
public static final short TAG_AFIB_WINDOW = 0x0009;
/** AFib continuous detection (always-active, timestamps = 0). */
public static final short TAG_AFIB_EXTRA = (short) 0x000A;
/** Night-time AFib detection. */
public static final short TAG_AFIB_NIGHT = (short) 0x000B;
/** Respiratory scan always-on mode (scans every night). */
public static final short TAG_RESP_ALWAYS = (short) 0x000E;
/** ECG / respiratory measurement feature enabled. */
public static final short TAG_ECG_MEAS = (short) 0x000F;
/** Companion to TAG_RESP_ALWAYS in always-on respiratory scan mode. */
public static final short TAG_0x0011 = (short) 0x0011;
/** Irregular heart rate detection. */
public static final short TAG_IRREGULAR_HR = (short) 0x0013;
/**
* Respiratory scan scheduling.
* <ul>
* <li>Absent -> respiratory scan off</li>
* <li>start=0, end=0 -> always-on mode</li>
* <li>start=now, end=noon-next-day -> automatic mode</li>
* </ul>
*/
public static final short TAG_RESP_SCAN = 0x0009;
/** Respiratory scan base tag; present whenever the respiratory feature has been activated. */
public static final short TAG_RESP_BASE = (short) 0x000A;
/** Respiratory scan automatic LED scheduling; present only in automatic mode. */
public static final short TAG_RESP_AUTO = (short) 0x000B;
/** AFib detection part 1. */
public static final short TAG_AFIB = (short) 0x000E;
/** SpO2 / oxygen saturation measurement enabled. */
public static final short TAG_SPO2_MEAS = (short) 0x000F;
/** AFib detection part 2 (companion to {@link #TAG_AFIB}). */
public static final short TAG_AFIB_2 = (short) 0x0011;
/** On-watch notifications enabled. */
public static final short TAG_NOTIFICATIONS = (short) 0x0013;
/** High heart rate notification. */
public static final short TAG_HIGH_HR = (short) 0x0014;
/** Low heart rate notification. */
@@ -0,0 +1,106 @@
/* 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 one heart-rate alert threshold entry (TLV type {@code 0x09A5}) used in
* {@code CMD_SET_HR_ALERT_THRESHOLDS} (0x098e) messages.
*
* <p>Wire format: 2-byte type + 2-byte length (8) + 8-byte payload:
* <pre>
* pad (uint8) - always 0x00
* direction (uint8) - 0x01 = HIGH resting HR, 0x02 = LOW resting HR
* enabled (uint8) - 0x01 = alert enabled, 0x00 = alert disabled
* pad (uint32) - always 0x00000000
* threshold (uint8) - BPM value that triggers the alert
* </pre>
*
* <p>Two entries are always sent together - one for {@link #DIRECTION_HIGH} and one for
* {@link #DIRECTION_LOW} - preceded by a {@link FeatureTagsUserId} header and followed by
* {@link EndOfTransmission}.
*/
public class HrAlertThreshold extends WithingsStructure {
// ---- Direction constants ----
/** High resting heart rate alert direction. */
public static final byte DIRECTION_HIGH = 0x01;
/** Low resting heart rate alert direction. */
public static final byte DIRECTION_LOW = 0x02;
// ---- Enabled/disabled ----
public static final byte ENABLED = 0x01;
public static final byte DISABLED = 0x00;
// ---- Payload ----
private byte direction;
private byte enabled;
private byte thresholdBpm;
/** No-arg constructor required by {@link DataStructureFactory}. */
public HrAlertThreshold() {}
/**
* @param direction {@link #DIRECTION_HIGH} or {@link #DIRECTION_LOW}
* @param enabled {@link #ENABLED} or {@link #DISABLED}
* @param thresholdBpm BPM value at which the alert fires
*/
public HrAlertThreshold(byte direction, byte enabled, byte thresholdBpm) {
this.direction = direction;
this.enabled = enabled;
this.thresholdBpm = thresholdBpm;
}
public byte getDirection() { return direction; }
public byte getEnabled() { return enabled; }
public byte getThresholdBpm() { return thresholdBpm; }
@Override
public short getLength() {
// 4-byte TLV header + 8-byte payload
return 12;
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.put((byte) 0x00); // pad
buffer.put(direction);
buffer.put(enabled);
buffer.putInt(0x00000000); // pad (4 bytes)
buffer.put(thresholdBpm);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer buffer) {
if (buffer.remaining() >= 8) {
buffer.get(); // skip pad
direction = buffer.get();
enabled = buffer.get();
buffer.getInt(); // skip pad
thresholdBpm = buffer.get();
}
}
@Override
public short getType() {
return WithingsStructureType.HR_ALERT_THRESHOLD;
}
}
@@ -18,15 +18,44 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.com
import java.nio.ByteBuffer;
/**
* Represents a single screen entry in the SET_SCREEN_LIST (0x050C) command.
*
* <p>Wire format: TLV type 0x0516, 22 bytes total (4-byte TLV header + 18-byte payload).
* Payload layout (all multi-byte fields are big-endian):
* <pre>
* Offset Size Field
* 0 4 id - screen identifier (e.g. 0x84 = Date)
* 4 4 userId - Withings account user ID (must be non-zero or the watch reboots)
* 8 4 reserved1 - always 0x00000000 in all observed captures
* 12 4 reserved2 - always 0x00000000 in all observed captures
* 16 1 idOnDevice - fixed per-screen byte; does NOT determine display order
* 17 1 screenType - 0x01 for built-in screens, 0x02 for partner/third-party screens
* </pre>
*
* <p>The {@code reserved1} and {@code reserved2} fields were zero in every entry across all five
* BLE packet captures analysed (covering default order, reordered, Strava added, Strava moved,
* and screens removed). They are preserved for round-trip fidelity but are not expected to carry
* meaningful data.
*
* <p>The {@code screenType} field was 0x01 for every built-in screen and 0x02 only for the
* Strava "Weekly distance" screen. It may distinguish built-in vs partner/third-party screens.
*/
public class ScreenSettings extends WithingsStructure {
private int id;
private int userId = 0;
private int yetUnknown1 = 0;
private int yetUnknown2 = 0;
/** Always 0x00000000 in all observed captures. */
private int reserved1 = 0;
/** Always 0x00000000 in all observed captures. */
private int reserved2 = 0;
private byte idOnDevice;
private byte yetUnkown3 = 0x01;
/**
* Screen type: 0x01 for built-in screens, 0x02 for partner/third-party screens (e.g. Strava).
* Defaults to 0x01 (built-in).
*/
private byte screenType = 0x01;
public int getId() {
return id;
@@ -52,6 +81,19 @@ public class ScreenSettings extends WithingsStructure {
this.idOnDevice = idOnDevice;
}
public byte getScreenType() {
return screenType;
}
/**
* Sets the screen type byte.
*
* @param screenType 0x01 for built-in screens (default), 0x02 for partner/third-party screens
*/
public void setScreenType(byte screenType) {
this.screenType = screenType;
}
@Override
public short getLength() {
return 22;
@@ -61,20 +103,20 @@ public class ScreenSettings extends WithingsStructure {
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
this.id = rawDataBuffer.getInt();
this.userId = rawDataBuffer.getInt();
this.yetUnknown1 = rawDataBuffer.getInt();
this.yetUnknown2 = rawDataBuffer.getInt();
this.reserved1 = rawDataBuffer.getInt();
this.reserved2 = rawDataBuffer.getInt();
this.idOnDevice = rawDataBuffer.get();
this.yetUnkown3 = rawDataBuffer.get();
this.screenType = rawDataBuffer.get();
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.putInt(this.id);
buffer.putInt(this.userId);
buffer.putInt(this.yetUnknown1);
buffer.putInt(this.yetUnknown2);
buffer.putInt(this.reserved1);
buffer.putInt(this.reserved2);
buffer.put(this.idOnDevice);
buffer.put(this.yetUnkown3);
buffer.put(this.screenType);
}
@Override
@@ -95,6 +95,15 @@ public class User extends WithingsStructure {
addStringAsBytesWithLengthByte(rawDataBuffer, name);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer buffer) {
// Parse the GET_USER (0x0503) response from the watch.
// The first 4 bytes are the Withings account userId; remaining fields are not needed.
if (buffer.remaining() >= 4) {
userID = buffer.getInt();
}
}
@Override
public short getType() {
return WithingsStructureType.USER;
@@ -20,15 +20,24 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.com
* 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).
* (4-byte big-endian int). The {@code idOnDevice} field is a separate fixed byte per screen whose
* value was confirmed from BLE captures; it does <em>not</em> determine display order. Display
* order on the watch is determined solely by the sequence of entries in the SET_SCREEN_LIST packet.
*
* <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>ScanWatch screen IDs and idOnDevice values</b> were confirmed from five BLE captures of
* the Withings HealthMate app sending CMD_SCREEN_LIST_SET (0x050C):
* <ul>
* <li>Capture 1 - default order: Date, Sleep, ECG, Elevation, HeartRate, SpO2, Calories,
* Settings, Workouts, Distance, Steps, Breathe, Clock</li>
* <li>Capture 2 - reordered: Date, Elevation, HeartRate, SpO2, Calories, Settings, Workouts,
* Distance, Steps, Breathe, Clock, Sleep, ECG</li>
* <li>Capture 3 - Strava added: 14 screens including Strava "Weekly distance"
* (screen_id=0x99, idOnDevice=0x02, screenType=0x02)</li>
* <li>Capture 4 - Strava moved to position 2</li>
* <li>Capture 5 - 5 screens removed (9 screens total)</li>
* </ul>
* Every screen_id and idOnDevice value is identical across all captures; only the packet entry
* sequence differs, confirming that sequence - not idOnDevice - drives display order.
*
* <p><b>Steel HR screen IDs</b> come from the original Gadgetbridge implementation and have not
* been reverified against a fresh packet capture.
@@ -63,73 +72,110 @@ public final class WithingsScreenId {
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.
// Screens - Withings ScanWatch
// screen_id and idOnDevice values confirmed from BLE packet captures.
// idOnDevice is a fixed per-screen value; display order is determined by
// the sequence of entries in the SET_SCREEN_LIST packet, not by idOnDevice.
// -----------------------------------------------------------------------
/** Date / activity data screen. Screen ID 0x007f, fixed slot 1. */
public static final int DATE = 0x007f;
/** Date / activity data screen. screen_id=0x84, idOnDevice=0x06. */
public static final int DATE = 0x84;
/** Sleep screen. Screen ID 0x0080, fixed slot 2. */
public static final int SLEEP = 0x0080;
/** Sleep screen. screen_id=0x160, idOnDevice=0x16. */
public static final int SLEEP = 0x160;
/** ECG screen. Screen ID 0x0081, fixed slot 3. */
public static final int ECG = 0x0081;
/** ECG screen. screen_id=0x8a, idOnDevice=0x09. */
public static final int ECG = 0x8a;
/** Elevation screen. Screen ID 0x0082, fixed slot 4. */
public static final int ELEVATION = 0x0082;
/** Elevation screen. screen_id=0x8c, idOnDevice=0x0c. */
public static final int ELEVATION = 0x8c;
/** Heart rate screen. Screen ID 0x0084, fixed slot 6. */
public static final int HEART_RATE = 0x0084;
/** Heart rate screen. screen_id=0x82, idOnDevice=0x04. */
public static final int HEART_RATE = 0x82;
/** SpO2 / blood oxygen screen. Screen ID 0x008a, fixed slot 9. */
public static final int SPO2 = 0x008a;
/** SpO2 / blood oxygen screen. screen_id=0x8b, idOnDevice=0x0a. */
public static final int SPO2 = 0x8b;
/** Calories screen. Screen ID 0x008b, fixed slot 10. */
public static final int CALORIES = 0x008b;
/** Calories screen. screen_id=0x81, idOnDevice=0x03. */
public static final int CALORIES = 0x81;
/** Settings screen. Screen ID 0x0089, fixed slot 11. */
public static final int SETTINGS = 0x0089;
/** Settings screen. screen_id=0x97, idOnDevice=0x11. */
public static final int SETTINGS = 0x97;
/** Workouts screen. Screen ID 0x008c, fixed slot 12. */
public static final int WORKOUTS = 0x008c;
/** Workouts screen. screen_id=0x89, idOnDevice=0x0b. */
public static final int WORKOUTS = 0x89;
/** Distance screen. Screen ID 0x0096, fixed slot 16. */
public static final int DISTANCE = 0x0096;
/** Distance screen. screen_id=0x80, idOnDevice=0x02. */
public static final int DISTANCE = 0x80;
/** Steps screen. Screen ID 0x0097, fixed slot 17. */
public static final int STEPS = 0x0097;
/** Steps screen. screen_id=0x7f, idOnDevice=0x01. */
public static final int STEPS = 0x7f;
/** Breathe / breathing exercises screen. Screen ID 0x00a1, fixed slot 18. */
public static final int BREATHE = 0x00a1;
/** Breathe / breathing exercises screen. screen_id=0xa1, idOnDevice=0x12. */
public static final int BREATHE = 0xa1;
/** Clock (alarms, stopwatch, timer) screen. Screen ID 0x0160, fixed slot 22. */
public static final int CLOCK = 0x0160;
/** Clock (alarms, stopwatch, timer) screen. screen_id=0x96, idOnDevice=0x10. */
public static final int CLOCK = 0x96;
// -----------------------------------------------------------------------
// Screens - Withings ScanWatch - Partner / third-party app screens
// These use screenType=0x02 in the ScreenSettings entry instead of 0x01.
// -----------------------------------------------------------------------
/**
* 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.
* Strava "Weekly distance" screen. screen_id=0x99, idOnDevice=0x02, screenType=0x02.
*
* <p>This screen appears when Strava is linked in the Withings Health Mate app.
* Unlike built-in screens which use {@code screenType=0x01}, the Strava screen uses
* {@code screenType=0x02}, likely indicating a partner/third-party screen.
*
* <p>Note: {@code idOnDevice=0x02} is the same value as the Distance screen's idOnDevice.
* The two are distinguished by their different screen_id values (0x99 vs 0x80).
*/
public static final int STRAVA_WEEKLY_DISTANCE = 0x99;
/**
* Returns the fixed {@code idOnDevice} byte for a given ScanWatch screen ID.
*
* <p>This value is a fixed property of each screen confirmed from BLE captures. It is
* <em>not</em> a position index - display order is determined by packet entry sequence.
*
* @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
* @return the fixed idOnDevice byte, or -1 if the screen ID is not a known ScanWatch screen
*/
public static byte getScanwatchSlot(int screenId) {
public static byte getScanwatchIdOnDevice(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;
case DATE: return 0x06;
case SLEEP: return 0x16;
case ECG: return 0x09;
case ELEVATION: return 0x0c;
case HEART_RATE: return 0x04;
case SPO2: return 0x0a;
case CALORIES: return 0x03;
case SETTINGS: return 0x11;
case WORKOUTS: return 0x0b;
case DISTANCE: return 0x02;
case STEPS: return 0x01;
case BREATHE: return 0x12;
case CLOCK: return 0x10;
case STRAVA_WEEKLY_DISTANCE: return 0x02;
default: return -1;
}
}
/**
* Returns the {@code screenType} byte for a given ScanWatch screen ID.
*
* <p>Built-in screens use 0x01; partner/third-party screens (e.g. Strava) use 0x02.
* Confirmed from BLE captures of the Withings HealthMate app.
*
* @param screenId one of the ScanWatch screen ID constants in this class
* @return 0x01 for built-in screens, 0x02 for partner screens, or 0x01 as default
*/
public static byte getScanwatchScreenType(int screenId) {
switch (screenId) {
case STRAVA_WEEKLY_DISTANCE: return 0x02;
default: return 0x01;
}
}
}
@@ -80,6 +80,18 @@ public class WithingsStructureType {
/** Local (on-watch) notification slot configuration (type 0x09A8 = 2472). */
public static final short LOCAL_NOTIFICATION = (short) 0x09A8; // 2472
/**
* Heart-rate alert threshold entry (type 0x09A5 = 2469), 8-byte payload:
* <pre>
* pad (uint8) - always 0x00
* direction (uint8) - 0x01 = HIGH, 0x02 = LOW
* enabled (uint8) - 0x01 = enabled, 0x00 = disabled
* pad (uint32) - always 0x00000000
* threshold (uint8) - BPM value
* </pre>
*/
public static final short HR_ALERT_THRESHOLD = (short) 0x09A5; // 2469
/** Quicklook / glance (raise-to-wake) status (type 0x097A = 2426). */
public static final short GLANCE_STATUS = (short) 0x097A; // 2426
@@ -86,6 +86,14 @@ public final class WithingsMessageType {
*/
public static final short SET_LOCAL_NOTIFICATIONS = (short) 0x0990; // 2448
/**
* Set heart-rate alert thresholds (cmd 0x098e).
* Configures the BPM thresholds and enabled/disabled state for high and low resting HR alerts.
* The message body contains a {@code FeatureTagsUserId} header followed by one
* {@code HrAlertThreshold} TLV for LOW and one for HIGH, then {@code EndOfTransmission}.
*/
public static final short SET_HR_ALERT_THRESHOLDS = (short) 0x098e; // 2446
/** Get quicklook / glance (raise-to-wake) status (cmd 0x097B). */
public static final short GLANCE_GET = (short) 0x097B; // 2427
/** Set quicklook / glance (raise-to-wake) status (cmd 0x0971). */
+43
View File
@@ -1925,6 +1925,49 @@
<item>always</item>
</string-array>
<string-array name="pref_withings_scanwatch_hr_alert_mode_entries">
<item>@string/withings_scanwatch_pref_hr_alert_off</item>
<item>@string/withings_scanwatch_pref_hr_alert_automatic</item>
<item>@string/withings_scanwatch_pref_hr_alert_custom</item>
</string-array>
<string-array name="pref_withings_scanwatch_hr_alert_mode_values">
<item>off</item>
<item>automatic</item>
<item>custom</item>
</string-array>
<string-array name="pref_withings_scanwatch_hr_alert_low_entries">
<item>@string/heartrate_bpm_35</item>
<item>@string/heartrate_bpm_40</item>
<item>@string/heartrate_bpm_45</item>
<item>@string/heartrate_bpm_50</item>
<item>@string/heartrate_bpm_55</item>
</string-array>
<string-array name="pref_withings_scanwatch_hr_alert_low_values">
<item>35</item>
<item>40</item>
<item>45</item>
<item>50</item>
<item>55</item>
</string-array>
<string-array name="pref_withings_scanwatch_hr_alert_high_entries">
<item>@string/heartrate_bpm_100</item>
<item>@string/heartrate_bpm_110</item>
<item>@string/heartrate_bpm_120</item>
<item>@string/heartrate_bpm_130</item>
<item>@string/heartrate_bpm_140</item>
<item>@string/heartrate_bpm_150</item>
</string-array>
<string-array name="pref_withings_scanwatch_hr_alert_high_values">
<item>100</item>
<item>110</item>
<item>120</item>
<item>130</item>
<item>140</item>
<item>150</item>
</string-array>
<string-array name="pref_neo_display_items">
<item>@string/menuitem_pai</item>
<item>@string/menuitem_dnd</item>
+10
View File
@@ -847,9 +847,11 @@
<string name="interval_twenty_minutes">every 20 minutes</string>
<string name="interval_thirty_minutes">every 30 minutes</string>
<string name="interval_forty_five_minutes">every 45 minutes</string>
<string name="heartrate_bpm_35">35 bpm</string>
<string name="heartrate_bpm_40">40 bpm</string>
<string name="heartrate_bpm_45">45 bpm</string>
<string name="heartrate_bpm_50">50 bpm</string>
<string name="heartrate_bpm_55">55 bpm</string>
<string name="heartrate_bpm_100">100 bpm</string>
<string name="heartrate_bpm_105">105 bpm</string>
<string name="heartrate_bpm_110">110 bpm</string>
@@ -3801,6 +3803,14 @@
<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="withings_scanwatch_pref_hr_alert_title">High/low resting heart rate alerts</string>
<string name="withings_scanwatch_pref_hr_alert_off">Off</string>
<string name="withings_scanwatch_pref_hr_alert_automatic">Automatic (recommended)</string>
<string name="withings_scanwatch_pref_hr_alert_custom">Custom threshold</string>
<string name="withings_scanwatch_pref_hr_alert_low_title">Low heart rate threshold</string>
<string name="withings_scanwatch_pref_hr_alert_low_summary">Alert when resting heart rate drops below this value (bpm)</string>
<string name="withings_scanwatch_pref_hr_alert_high_title">High heart rate threshold</string>
<string name="withings_scanwatch_pref_hr_alert_high_summary">Alert when resting heart rate exceeds this value (bpm)</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>
@@ -80,6 +80,38 @@
android:summary="@string/withings_scanwatch_pref_afib_night_summary"
android:title="@string/withings_scanwatch_pref_afib_night_title" />
<!-- High/low resting heart rate alerts -->
<ListPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="off"
android:entries="@array/pref_withings_scanwatch_hr_alert_mode_entries"
android:entryValues="@array/pref_withings_scanwatch_hr_alert_mode_values"
android:key="withings_scanwatch_hr_alert_mode"
android:persistent="true"
android:title="@string/withings_scanwatch_pref_hr_alert_title" />
<!-- Custom low HR threshold (only meaningful when mode = custom) -->
<ListPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="40"
android:entries="@array/pref_withings_scanwatch_hr_alert_low_entries"
android:entryValues="@array/pref_withings_scanwatch_hr_alert_low_values"
android:key="withings_scanwatch_hr_alert_low"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_hr_alert_low_summary"
android:title="@string/withings_scanwatch_pref_hr_alert_low_title" />
<!-- Custom high HR threshold (only meaningful when mode = custom) -->
<ListPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="100"
android:entries="@array/pref_withings_scanwatch_hr_alert_high_entries"
android:entryValues="@array/pref_withings_scanwatch_hr_alert_high_values"
android:key="withings_scanwatch_hr_alert_high"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_hr_alert_high_summary"
android:title="@string/withings_scanwatch_pref_hr_alert_high_title" />
<!-- Quicklook / glance (raise-to-wake) -->
<SwitchPreference
android:icon="@drawable/ic_always_on_display"