mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
feat(withings,scanwatch): Screen order
This commit is contained in:
+75
@@ -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
|
||||
|
||||
+325
-53
@@ -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.
|
||||
|
||||
+13
-1
@@ -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());
|
||||
|
||||
+64
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
+32
-23
@@ -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 & 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 & 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. */
|
||||
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
+51
-9
@@ -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
|
||||
|
||||
+9
@@ -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;
|
||||
|
||||
+100
-54
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
|
||||
+8
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user