feat(withings,scanwatch): Brightness support, 5 alarms, quicklook support, light sleep

This commit is contained in:
d3vv3
2026-03-28 13:15:06 +01:00
parent c55f0fe1af
commit bc3c691095
36 changed files with 1620 additions and 162 deletions
@@ -84,6 +84,8 @@ public class AlarmDetails extends AbstractGBActivity {
boolean smartAlarmSupported = supportsSmartWakeup(alarm.getPosition());
boolean smartAlarmForced = forcedSmartWakeup(alarm.getPosition());
boolean smartAlarmIntervalSupported = supportsSmartWakeupInterval(alarm.getPosition());
final int smartWakeupMax = getSmartWakeupMaxInterval();
final String smartWakeupDescription = getSmartWakeupDescription();
binding.cbSmartWakeup.setChecked(alarm.getSmartWakeup() || smartAlarmForced);
binding.cbSmartWakeup.setVisibility(smartAlarmSupported ? View.VISIBLE : View.GONE);
@@ -105,19 +107,20 @@ public class AlarmDetails extends AbstractGBActivity {
if (alarm.getSmartWakeupInterval() != null) {
binding.cbSmartWakeupInterval.setText(NumberFormat.getInstance().format(alarm.getSmartWakeupInterval()));
}
final int maxDigits = String.valueOf(smartWakeupMax).length();
binding.cbSmartWakeupInterval.setFilters(new InputFilter[]{
new InputFilter() {
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
if (dend >= 3) // Limit length
if (dend >= maxDigits + 1) // Limit length
return "";
String strValue = dest.subSequence(0, dstart) + source.subSequence(start, end).toString() + dest.subSequence(dend, dest.length());
try {
int value = Integer.parseInt(strValue);
if (value > 255) {
binding.cbSmartWakeupInterval.setText("255");
binding.cbSmartWakeupInterval.setSelection(3); // Move cursor to end
if (value > smartWakeupMax) {
binding.cbSmartWakeupInterval.setText(String.valueOf(smartWakeupMax));
binding.cbSmartWakeupInterval.setSelection(String.valueOf(smartWakeupMax).length()); // Move cursor to end
}
} catch (NumberFormatException e) {
return "";
@@ -127,6 +130,13 @@ public class AlarmDetails extends AbstractGBActivity {
}
});
if (smartAlarmIntervalSupported && smartWakeupDescription != null) {
binding.tvSmartWakeupDescription.setText(smartWakeupDescription);
binding.tvSmartWakeupDescription.setVisibility(View.VISIBLE);
} else {
binding.tvSmartWakeupDescription.setVisibility(View.GONE);
}
binding.cbSnooze.setChecked(alarm.getSnooze());
binding.cbSnooze.setVisibility(supportsSnoozing() ? View.VISIBLE : View.GONE);
@@ -195,6 +205,14 @@ public class AlarmDetails extends AbstractGBActivity {
return device.getDeviceCoordinator().supportsSmartWakeupInterval(device, position);
}
private int getSmartWakeupMaxInterval() {
return device.getDeviceCoordinator().getSmartWakeupMaxInterval(device);
}
private String getSmartWakeupDescription() {
return device.getDeviceCoordinator().getSmartWakeupDescription(device);
}
/**
* The alarm at this position *must* be a smart alarm
*/
@@ -546,6 +546,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
@Override
public int getSmartWakeupMaxInterval(@NonNull GBDevice device) {
return 255;
}
@Override
public String getSmartWakeupDescription(@NonNull GBDevice device) {
return null;
}
@Override
public boolean forcedSmartWakeup(GBDevice device, int alarmPosition) {
return false;
@@ -550,6 +550,19 @@ public interface DeviceCoordinator {
*/
boolean supportsSmartWakeupInterval(@NonNull GBDevice device, int alarmPosition);
/**
* Returns the maximum smart wakeup interval in minutes supported by this device.
* Defaults to 255. Override to restrict (e.g. Scanwatch supports 0-60).
*/
int getSmartWakeupMaxInterval(@NonNull GBDevice device);
/**
* Returns an optional human-readable description explaining how smart wakeup works on this
* device, or {@code null} if no description should be shown.
*/
@Nullable
String getSmartWakeupDescription(@NonNull GBDevice device);
/**
* Returns true if the alarm at the specified position *must* be a smart alarm for this device/coordinator
* @param alarmPosition Position of the alarm
@@ -24,6 +24,7 @@ import java.util.regex.Pattern;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
@@ -51,7 +52,7 @@ public class WithingsScanwatchDeviceCoordinator extends AbstractBLEDeviceCoordin
@Override
public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
return new int[]{ R.xml.devicesettings_withingsscanwatch };
return new int[]{ R.xml.devicesettings_withingsscanwatch, R.xml.devicesettings_wearlocation };
}
@Override
@@ -91,7 +92,7 @@ public class WithingsScanwatchDeviceCoordinator extends AbstractBLEDeviceCoordin
@Override
public int getAlarmSlotCount(GBDevice gbDevice) {
return 3;
return 5;
}
@Override
@@ -109,6 +110,21 @@ public class WithingsScanwatchDeviceCoordinator extends AbstractBLEDeviceCoordin
return true;
}
@Override
public boolean supportsSmartWakeupInterval(@NonNull GBDevice device, int alarmPosition) {
return true;
}
@Override
public int getSmartWakeupMaxInterval(@NonNull GBDevice device) {
return 60;
}
@Override
public String getSmartWakeupDescription(@NonNull GBDevice device) {
return GBApplication.getContext().getString(R.string.withings_scanwatch_alarm_smart_wakeup_description);
}
@Override
public boolean supportsHeartRateMeasurement(GBDevice device) {
return true;
@@ -58,21 +58,12 @@ public class WithingsScanwatchSampleProvider extends AbstractSampleProvider<With
@Override
public ActivityKind normalizeType(int rawType) {
switch (rawType) {
case 1: return ActivityKind.LIGHT_SLEEP;
case 2: return ActivityKind.DEEP_SLEEP;
default: return ActivityKind.fromCode(rawType);
}
return ActivityKind.fromCode(rawType);
}
@Override
public int toRawActivityKind(ActivityKind activityKind) {
switch (activityKind) {
case UNKNOWN: return 0;
case LIGHT_SLEEP: return 1;
case DEEP_SLEEP: return 2;
default: return activityKind.getCode();
}
return activityKind.getCode();
}
@Override
@@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.devices.withingsscanwatch;
import android.os.Parcel;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
@@ -30,26 +31,74 @@ import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
static final String PREF_SCREENS_SORTABLE = "withings_scanwatch_screens_sortable";
static final String PREF_SHORTCUT_ACTION = "withings_scanwatch_shortcut_action";
static final String PREF_ECG_ENABLED = "withings_scanwatch_ecg_enabled";
static final String PREF_AFIB_ENABLED = "withings_scanwatch_afib_enabled";
static final String PREF_SCREENS_SORTABLE = "withings_scanwatch_screens_sortable";
static final String PREF_SHORTCUT_ACTION = "withings_scanwatch_shortcut_action";
static final String PREF_ECG_ENABLED = "withings_scanwatch_ecg_enabled";
static final String PREF_RESPIRATORY_SCAN = "withings_scanwatch_respiratory_scan";
static final String PREF_AFIB_DAY_ENABLED = "withings_scanwatch_afib_day_enabled";
static final String PREF_AFIB_NIGHT_ENABLED= "withings_scanwatch_afib_night_enabled";
static final String PREF_QUICKLOOK = "withings_scanwatch_quicklook";
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";
@Override
public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs, final String rootKey) {
handler.addPreferenceHandlerFor(PREF_SCREENS_SORTABLE);
handler.addPreferenceHandlerFor(PREF_SHORTCUT_ACTION);
handler.addPreferenceHandlerFor(PREF_ECG_ENABLED);
handler.addPreferenceHandlerFor(PREF_AFIB_ENABLED);
handler.addPreferenceHandlerFor(PREF_RESPIRATORY_SCAN);
handler.addPreferenceHandlerFor(PREF_AFIB_DAY_ENABLED);
handler.addPreferenceHandlerFor(PREF_AFIB_NIGHT_ENABLED);
handler.addPreferenceHandlerFor(PREF_QUICKLOOK);
handler.addPreferenceHandlerFor(PREF_AUTO_BRIGHTNESS);
handler.addPreferenceHandlerFor(PREF_MOVE_HANDS);
// Brightness level is only meaningful when auto-brightness is OFF (inverse dependency)
final EditTextPreference brightnessPref = handler.findPreference(PREF_BRIGHTNESS_LEVEL);
if (brightnessPref != null) {
final boolean autoOn = prefs.getBoolean(PREF_AUTO_BRIGHTNESS, true);
brightnessPref.setEnabled(!autoOn);
}
// Register brightness level handler with validation: integer only, clamped to 0-100
handler.addPreferenceHandlerFor(PREF_BRIGHTNESS_LEVEL, (pref, newValue) -> {
final String raw = String.valueOf(newValue).trim();
try {
final int val = Integer.parseInt(raw);
final int clamped = Math.max(0, Math.min(100, val));
if (clamped != val && pref instanceof EditTextPreference) {
// Persist the clamped value instead of the out-of-range one
((EditTextPreference) pref).setText(String.valueOf(clamped));
return false; // reject original; clamped value already persisted
}
} catch (NumberFormatException e) {
return false; // reject non-integer input
}
return true;
});
final ListPreference shortcutPref = handler.findPreference(PREF_SHORTCUT_ACTION);
if (shortcutPref != null) {
shortcutPref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
}
final ListPreference respiratoryPref = handler.findPreference(PREF_RESPIRATORY_SCAN);
if (respiratoryPref != null) {
respiratoryPref.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
}
}
@Override
public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
if (PREF_AUTO_BRIGHTNESS.equals(preference.getKey())) {
final EditTextPreference brightnessPref = handler.findPreference(PREF_BRIGHTNESS_LEVEL);
if (brightnessPref != null) {
// preference value has already been persisted at this point
final boolean autoOn = preference.getSharedPreferences().getBoolean(PREF_AUTO_BRIGHTNESS, true);
brightnessPref.setEnabled(!autoOn);
}
}
}
@Override
@@ -23,6 +23,7 @@ import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
@@ -51,12 +52,17 @@ public class WithingsCalibrationActivity extends AbstractGBActivity {
private GBDevice device;
private LocalBroadcastManager localBroadcastManager;
private final String[] calibrationAdvices = new String[3];
private final String[] appDialAdvices = new String[3];
private final String[] crownAdvices = new String[3];
private final Hands[] hands = new Hands[]{Hands.HOURS, Hands.MINUTES, Hands.ACTIVITY_TARGET};
private short handIndex = 0;
private Button previousButton;
private Button nextButton;
private Button okButton;
private Button confirmButton;
private RotaryControl rotaryControl;
private TextView textView;
private boolean crownMode = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -90,12 +96,18 @@ public class WithingsCalibrationActivity extends AbstractGBActivity {
}
private void initView() {
appDialAdvices[0] = getString(R.string.withings_calibration_text_hours);
appDialAdvices[1] = getString(R.string.withings_calibration_text_minutes);
appDialAdvices[2] = getString(R.string.withings_calibration_text_activity_target);
calibrationAdvices[0] = getString(R.string.withings_calibration_text_hours);
calibrationAdvices[1] = getString(R.string.withings_calibration_text_minutes);
calibrationAdvices[2] = getString(R.string.withings_calibration_text_activity_target);
crownAdvices[0] = getString(R.string.withings_calibration_crown_text_hours);
crownAdvices[1] = getString(R.string.withings_calibration_crown_text_minutes);
crownAdvices[2] = getString(R.string.withings_calibration_crown_text_activity_target);
textView = findViewById(R.id.withings_calibration_textview);
rotaryControl = findViewById(R.id.rotary_control);
confirmButton = findViewById(R.id.withings_calibration_button_confirm);
RotaryControl rotaryControl = findViewById(R.id.rotary_control);
rotaryControl.setRotationListener(new RotaryControl.RotationListener() {
@Override
public void onRotation(short movementAmount) {
@@ -106,26 +118,39 @@ public class WithingsCalibrationActivity extends AbstractGBActivity {
}
});
TextView textView = findViewById(R.id.withings_calibration_textview);
textView.setText(calibrationAdvices[0]);
// Crown mode: confirm button registers current position as 12 o'clock
confirmButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent calibration = new Intent(WithingsBaseDeviceSupport.HANDS_CALIBRATION_CMD);
calibration.putExtra("hand", hands[handIndex].code);
calibration.putExtra("movementAmount", (short) 0);
localBroadcastManager.sendBroadcast(calibration);
// Auto-advance to next hand or finish
if (handIndex < 2) {
handIndex++;
updateUI();
} else {
finish();
}
}
});
previousButton = findViewById(R.id.withings_calibration_button_previous);
previousButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
handIndex--;
enableButtons();
textView.setText(calibrationAdvices[handIndex]);
rotaryControl.reset();
updateUI();
}
});
nextButton = findViewById(R.id.withings_calibration_button_next);
nextButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
handIndex++;
enableButtons();
textView.setText(calibrationAdvices[handIndex]);
rotaryControl.reset();
updateUI();
}
});
@@ -137,7 +162,55 @@ public class WithingsCalibrationActivity extends AbstractGBActivity {
}
});
enableButtons();
// Mode toggle
RadioGroup modeGroup = findViewById(R.id.withings_calibration_mode_group);
modeGroup.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(RadioGroup group, int checkedId) {
crownMode = (checkedId == R.id.withings_calibration_mode_crown);
updateUI();
}
});
updateUI();
}
private void updateUI() {
if (crownMode) {
rotaryControl.setVisibility(View.GONE);
confirmButton.setVisibility(View.VISIBLE);
// Hide Previous/Next/OK - crown mode auto-advances on Confirm
previousButton.setVisibility(View.GONE);
nextButton.setVisibility(View.GONE);
okButton.setVisibility(View.GONE);
textView.setText(crownAdvices[handIndex]);
// Send a tiny movement to tell the watch which hand the crown should
// control. Without this the firmware doesn't know which hand is active
// and the crown may move the wrong hand (or none at all).
selectHandForCrown();
} else {
rotaryControl.setVisibility(View.VISIBLE);
confirmButton.setVisibility(View.GONE);
previousButton.setVisibility(View.VISIBLE);
nextButton.setVisibility(View.VISIBLE);
okButton.setVisibility(View.VISIBLE);
textView.setText(appDialAdvices[handIndex]);
rotaryControl.reset();
enableButtons();
}
}
/**
* Sends a minimal MOVE_HAND command to activate the current hand for crown
* input. The watch firmware only routes crown rotation to a hand after it
* has received at least one MOVE_HAND for that hand.
*/
private void selectHandForCrown() {
if (localBroadcastManager == null) return;
Intent calibration = new Intent(WithingsBaseDeviceSupport.HANDS_CALIBRATION_CMD);
calibration.putExtra("hand", hands[handIndex].code);
calibration.putExtra("movementAmount", (short) 1);
localBroadcastManager.sendBroadcast(calibration);
}
@Override
@@ -156,4 +229,4 @@ public class WithingsCalibrationActivity extends AbstractGBActivity {
previousButton.setEnabled(handIndex > 0);
okButton.setEnabled(handIndex == 2);
}
}
}
@@ -58,28 +58,12 @@ public class WithingsSteelHRSampleProvider extends AbstractSampleProvider<Within
@Override
public ActivityKind normalizeType(int rawType) {
switch (rawType) {
case 1:
return ActivityKind.LIGHT_SLEEP;
case 2:
return ActivityKind.DEEP_SLEEP;
default:
return ActivityKind.fromCode(rawType);
}
return ActivityKind.fromCode(rawType);
}
@Override
public int toRawActivityKind(ActivityKind activityKind) {
switch (activityKind) {
case UNKNOWN:
return 0;
case LIGHT_SLEEP:
return 1;
case DEEP_SLEEP:
return 2;
default:
return activityKind.getCode();
}
return activityKind.getCode();
}
@Override
@@ -27,6 +27,7 @@ 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.devices.AbstractSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.withingsscanwatch.WithingsScanwatchSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractWithingsActivitySample;
@@ -35,17 +36,34 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.WithingsUUIDs;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetShortcutHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetGlanceHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetLuminosityHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetMoveHandsHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetWearPosHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.EndOfTransmission;
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.LocalNotification;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LuminosityLevel;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ShortcutAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.TrackerMoveHands;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.TrackerWearPos;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsScreenId;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.WithingsMessageType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.IconHelper;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.activity.WithingsActivityType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ImageMetaData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WorkoutScreen;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.ExpectedResponse;
/**
* Device support for Withings Scanwatch. The Scanwatch uses the same BLE protocol as the
* Steel HR but with a different service UUID suffix (005d vs 0037).
@@ -70,10 +88,22 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
private static final String PREF_SHORTCUT_ACTION = "withings_scanwatch_shortcut_action";
/** Whether the ECG measurement feature is enabled on the watch (feature tag activation). */
static final String PREF_ECG_ENABLED = "withings_scanwatch_ecg_enabled";
/** Whether AFib detection (and night AFib) is enabled on the watch. */
static final String PREF_AFIB_ENABLED = "withings_scanwatch_afib_enabled";
/** Whether the ECG measurement feature is enabled on the watch. */
static final String PREF_ECG_ENABLED = "withings_scanwatch_ecg_enabled";
/** Respiratory scan mode: "off", "automatic" (some nights), or "always" (every night). */
static final String PREF_RESPIRATORY_SCAN = "withings_scanwatch_respiratory_scan";
/** Whether daytime AFib detection is enabled on the watch. */
static final String PREF_AFIB_DAY_ENABLED = "withings_scanwatch_afib_day_enabled";
/** Whether night-time AFib detection is enabled on the watch. */
static final String PREF_AFIB_NIGHT_ENABLED = "withings_scanwatch_afib_night_enabled";
/** Whether quicklook / glance (raise-to-wake) is enabled on the watch. */
static final String PREF_QUICKLOOK = "withings_scanwatch_quicklook";
/** Whether auto-brightness is enabled on the watch. */
static final String PREF_AUTO_BRIGHTNESS = "withings_scanwatch_auto_brightness";
/** Manual brightness level (0-100) when auto-brightness is off. */
static final String PREF_BRIGHTNESS_LEVEL = "withings_scanwatch_brightness_level";
/** 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";
@Override
protected void addExtraSyncCommands() {
@@ -81,6 +111,32 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
new WithingsMessage(WithingsMessageType.GET_SHORTCUT),
new GetShortcutHandler(this)
);
addSimpleConversationToQueue(
new WithingsMessage(WithingsMessageType.GLANCE_GET),
new GetGlanceHandler(this)
);
addSimpleConversationToQueue(
new WithingsMessage(WithingsMessageType.GET_LUMINOSITY_LEVEL),
new GetLuminosityHandler(this)
);
addSimpleConversationToQueue(
new WithingsMessage(WithingsMessageType.GET_TRACKER_MOVE_HANDS),
new GetMoveHandsHandler(this)
);
addSimpleConversationToQueue(
new WithingsMessage(WithingsMessageType.GET_TRACKER_WEAR_POS),
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.
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);
}
@Override
@@ -108,21 +164,51 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
sendQueue();
return true;
}
if (PREF_ECG_ENABLED.equals(config)) {
if (PREF_ECG_ENABLED.equals(config) || PREF_RESPIRATORY_SCAN.equals(config)
|| PREF_AFIB_DAY_ENABLED.equals(config) || PREF_AFIB_NIGHT_ENABLED.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean enabled = prefs.getBoolean(PREF_ECG_ENABLED, false);
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);
clearQueue();
addFeatureTagsCommand(enabled, prefs.getBoolean(PREF_AFIB_ENABLED, false));
addLocalNotificationsCommand(enabled, prefs.getBoolean(PREF_AFIB_ENABLED, false));
addFeatureTagsCommand(ecg, respScan, afibDay, afibNight);
addLocalNotificationsCommand(afibDay, afibNight);
sendQueue();
return true;
}
if (PREF_AFIB_ENABLED.equals(config)) {
if (PREF_QUICKLOOK.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean afibEnabled = prefs.getBoolean(PREF_AFIB_ENABLED, false);
final boolean enabled = prefs.getBoolean(PREF_QUICKLOOK, false);
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.GLANCE_SET,
new GlanceStatus(enabled));
clearQueue();
addFeatureTagsCommand(prefs.getBoolean(PREF_ECG_ENABLED, false), afibEnabled);
addLocalNotificationsCommand(prefs.getBoolean(PREF_ECG_ENABLED, false), afibEnabled);
addSimpleConversationToQueue(msg);
sendQueue();
return true;
}
if (PREF_AUTO_BRIGHTNESS.equals(config) || PREF_BRIGHTNESS_LEVEL.equals(config)) {
sendLuminosityLevel();
return true;
}
if (PREF_MOVE_HANDS.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean enabled = prefs.getBoolean(PREF_MOVE_HANDS, false);
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_TRACKER_MOVE_HANDS,
new TrackerMoveHands(enabled));
clearQueue();
addSimpleConversationToQueue(msg);
sendQueue();
return true;
}
if (DeviceSettingsPreferenceConst.PREF_WEARLOCATION.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final String location = prefs.getString(DeviceSettingsPreferenceConst.PREF_WEARLOCATION, "left");
final byte pos = "left".equals(location) ? TrackerWearPos.POS_LEFT_WRIST : TrackerWearPos.POS_RIGHT_WRIST;
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_TRACKER_WEAR_POS,
new TrackerWearPos(pos));
clearQueue();
addSimpleConversationToQueue(msg);
sendQueue();
return true;
}
@@ -130,31 +216,81 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
}
/**
* Queues a {@code CMD_FEATURE_TAGS_SET_DEPRECATED_V2} (0x0987) message activating the
* feature tags required for ECG and/or AFib.
*
* <p>The full tag set observed from HCI captures:
* <ul>
* <li>ECG enabled: tags 0x0004, 0x000F, 0x0035, 0x0058</li>
* <li>AFib enabled (in addition to ECG tags): 0x000A, 0x000B</li>
* </ul>
* All tags use startTime=0 / endTime=0 (always active).
*
* <p>Note: the Withings app also sends 0x0035 and 0x0058 (purpose unknown) as a baseline.
* Reads the current auto-brightness / brightness-level preferences and sends the
* corresponding {@code CMD_SET_LUMINOSITY_LEVEL} (0x0941) command to the watch.
*/
private void addFeatureTagsCommand(final boolean ecgEnabled, final boolean afibEnabled) {
private void sendLuminosityLevel() {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean autoMode = prefs.getBoolean(PREF_AUTO_BRIGHTNESS, true);
final byte mode;
final byte level;
if (autoMode) {
mode = LuminosityLevel.MODE_AUTO;
level = 0;
} else {
mode = LuminosityLevel.MODE_MANUAL;
int rawLevel;
try {
rawLevel = Integer.parseInt(prefs.getString(PREF_BRIGHTNESS_LEVEL, "50"));
} catch (NumberFormatException e) {
rawLevel = 50;
}
level = (byte) Math.max(0, Math.min(100, rawLevel));
}
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_LUMINOSITY_LEVEL,
new LuminosityLevel(mode, level));
clearQueue();
addSimpleConversationToQueue(msg);
sendQueue();
}
/**
* Queues a {@code CMD_FEATURE_TAGS_SET_DEPRECATED_V2} (0x0987) message activating the
* feature tags required for ECG, respiratory scan, and/or AFib.
*
* <p>Tag set from HCI captures:
* <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>
* </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.
*/
private void addFeatureTagsCommand(final boolean ecgEnabled, final String respiratoryScan,
final boolean afibDayEnabled, final boolean afibNightEnabled) {
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_FEATURE_TAGS_DEPRECATED);
msg.addDataStructure(new FeatureTagsUserId()); // userId = 0
final boolean respAutomatic = "automatic".equals(respiratoryScan);
final boolean respAlways = "always".equals(respiratoryScan);
final boolean respActive = respAutomatic || respAlways;
if (ecgEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_ECG_TERMS));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_ECG_MEAS));
}
if (afibEnabled) {
if (respAutomatic) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_SPO2_SLEEP));
}
if (respAlways) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_RESP_ALWAYS));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0011));
}
if (afibDayEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_WINDOW));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_EXTRA));
}
if (afibNightEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_NIGHT));
}
if (ecgEnabled || afibEnabled) {
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 (ecgEnabled || respActive || afibDayEnabled || afibNightEnabled) {
// Baseline tags always present when any health feature is active
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0035));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0058));
}
@@ -168,29 +304,80 @@ 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 {@code afibEnabled}</li>
* <li>ECG - always disabled (managed via feature tags)</li>
* <li>UNKNOWN_3 - always disabled</li>
* <li>HIGH_LOW_HR - enabled when {@code ecgEnabled} (observed in ECG capture)</li>
* <li>PPG_AFIB_NIGHT - enabled when {@code afibEnabled}</li>
* <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>SLOT_4 - purpose unknown; always disabled</li>
* <li>PPG_AFIB_NIGHT - enabled when AFib night is on</li>
* </ol>
*/
private void addLocalNotificationsCommand(final boolean ecgEnabled, final boolean afibEnabled) {
private void addLocalNotificationsCommand(final boolean afibDayEnabled,
final boolean afibNightEnabled) {
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_LOCAL_NOTIFICATIONS);
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_PPG_AFIB,
afibEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_ECG,
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_UNKNOWN_3,
LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_HIGH_LOW_HR,
ecgEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new LocalNotification(LocalNotification.NOTIF_PPG_AFIB_NIGHT,
afibEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
afibNightEnabled ? LocalNotification.STATUS_ENABLED : LocalNotification.STATUS_DISABLED));
msg.addDataStructure(new EndOfTransmission());
addSimpleConversationToQueue(msg);
}
/**
* 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.
*/
@NonNull
@Override
protected Message createWorkoutScreenMessage(String workoutType) {
final WithingsActivityType activityType = WithingsActivityType.fromPrefValue(workoutType);
final int code = activityType.getCode();
Message message = new WithingsMessage(WithingsMessageType.SET_WORKOUT_SCREEN, ExpectedResponse.NONE);
// Workout screen settings with correct mode and flags
WorkoutScreen workoutScreen = new WorkoutScreen();
workoutScreen.setId(code);
final int stringId = getContext().getResources().getIdentifier(
"activity_type_" + workoutType, "string", getContext().getPackageName());
workoutScreen.setName(getContext().getString(stringId));
workoutScreen.setMode(activityType.getWorkoutMode());
workoutScreen.yetunknown2 = activityType.getWorkoutFlags();
message.addDataStructure(workoutScreen);
// Get the drawable for this activity type
final int drawableId = activityType.toActivityKind().getIcon();
final Drawable drawable = getContext().getDrawable(drawableId);
// Icon 0: 20x20
ImageMetaData meta0 = new ImageMetaData();
meta0.setWidth((byte) 20);
meta0.setHeight((byte) 20);
message.addDataStructure(meta0);
ImageData data0 = new ImageData();
data0.setImageData(IconHelper.getIconBytesFromDrawable(drawable, 20, 20));
message.addDataStructure(data0);
// Icon 1: 28x28
ImageMetaData meta1 = new ImageMetaData();
meta1.setIndex((byte) 1);
meta1.setWidth((byte) 28);
meta1.setHeight((byte) 28);
message.addDataStructure(meta1);
ImageData data1 = new ImageData();
data1.setImageData(IconHelper.getIconBytesFromDrawable(drawable, 28, 28));
message.addDataStructure(data1);
return message;
}
/**
* Builds the Scanwatch screen list from the user's drag-sort preference.
*
@@ -26,9 +26,12 @@ import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil;
public class IconHelper {
public static byte[] getIconBytesFromDrawable(Drawable drawable) {
return getIconBytesFromDrawable(drawable, 22, 24);
}
public static byte[] getIconBytesFromDrawable(Drawable drawable, int width, int height) {
Bitmap bitmap = BitmapUtil.toBitmap(drawable);
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, 22, 24, true);
int size = scaledBitmap.getRowBytes() * scaledBitmap.getHeight();
Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, width, height, true);
return toByteArray(scaledBitmap);
}
@@ -53,6 +53,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
@@ -65,6 +66,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.BatteryStateHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.Conversation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ConversationQueue;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetAlarmHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.HeartRateHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.ResponseHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.SetupFinishedHandler;
@@ -246,7 +248,7 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_TIME, new Time()));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.DISTANCE, getUnit())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.CLOCK_MODE, getTimeMode())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(activityUser.getStepsGoal())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(ActivityTarget.GOAL_TYPE_STEPS, activityUser.getStepsGoal())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ANCS_STATUS));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ANCS_STATUS, new AncsStatus(true)));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_BATTERY_STATUS), new BatteryStateHandler(this));
@@ -288,12 +290,12 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
// This makes the "authentication" far easier. However if it turns out that this is needed, we would need to find a way to savely store a unique generated secret.
// message.addDataStructure(new UserSecret());
addSimpleConversationToQueue(message);
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(activityUser.getStepsGoal())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET, new ActivityTarget(ActivityTarget.GOAL_TYPE_STEPS, activityUser.getStepsGoal())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.DISTANCE, getUnit())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_USER_UNIT, new UserUnit(UserUnitConstants.CLOCK_MODE, getTimeMode())));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM_SETTINGS));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_SCREEN_SETTINGS), new ScreenSettingsHandler(this));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_MULTI_ALARM), new GetAlarmHandler(this));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM_ENABLED));
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_WORKOUT_SCREEN_LIST), new WorkoutScreenListHandler(this));
addExtraSyncCommands();
@@ -372,25 +374,32 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
@Override
public void onSetAlarms(ArrayList<? extends Alarm> alarms) {
if (alarms.size() > 3) {
throw new IllegalArgumentException("This device only has three alarm slots!");
}
if (alarms.size() == 0) {
return;
}
boolean noAlarmsEnabled = true;
conversationQueue.clear();
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.GET_ALARM));
// Build a single CMD_SET_MULTI_ALARM message containing ALL enabled alarms.
// The watch replaces its entire alarm list with the contents of this message,
// so sending one message per alarm would cause only the last one to survive.
Message multiAlarmMessage = new WithingsMessage(WithingsMessageType.SET_ALARM);
boolean anyAlarmEnabled = false;
for (Alarm alarm : alarms) {
if (alarm.getEnabled() && !alarm.getUnused()) {
noAlarmsEnabled = false;
addAlarm(alarm);
anyAlarmEnabled = true;
addAlarmToMessage(multiAlarmMessage, alarm);
}
}
if (noAlarmsEnabled) {
if (anyAlarmEnabled) {
addSimpleConversationToQueue(multiAlarmMessage);
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(true)));
} else {
// Send an empty SET_ALARM to clear all alarms, then disable
addSimpleConversationToQueue(multiAlarmMessage);
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(false)));
}
@@ -437,6 +446,9 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
case PREF_LANGUAGE:
setLanguage();
break;
case ActivityUser.PREF_USER_STEPS_GOAL:
sendStepsGoal();
break;
default:
if (!handleExtraConfiguration(config)) {
logger.debug("unknown configuration setting received: " + config);
@@ -447,6 +459,14 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
}
}
private void sendStepsGoal() {
ActivityUser user = new ActivityUser();
conversationQueue.clear();
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ACTIVITY_TARGET,
new ActivityTarget(ActivityTarget.GOAL_TYPE_STEPS, user.getStepsGoal())));
conversationQueue.send();
}
/**
* Called from {@link #onSendConfiguration(String)} for device-specific configuration keys
* not handled by the base class.
@@ -545,24 +565,40 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
}
}
private void addAlarm(Alarm alarm) {
private void addAlarmToMessage(Message multiAlarmMessage, Alarm alarm) {
AlarmSettings alarmSettings = new AlarmSettings();
alarmSettings.setHour((short) alarm.getHour());
alarmSettings.setMinute((short) alarm.getMinute());
alarmSettings.setDayOfWeek(mapRepetitionToWithingsValue(alarm));
if (alarm.getSmartWakeup()) {
// Healthmate has the possibility to change the minutecount, in GB we use a fixed value of 15
alarmSettings.setSmartWakeupMinutes((short) 15);
if (!alarm.isRepetitive()) {
// One-time alarm: set the target date so the watch knows when to fire.
// Without a date, flags=0x80 + date=00/00/00 is ambiguous and the watch
// may ignore the alarm entirely.
Calendar cal = AlarmUtils.toCalendar(alarm);
alarmSettings.setDayOfMonth((short) cal.get(Calendar.DAY_OF_MONTH));
alarmSettings.setMonth((short) (cal.get(Calendar.MONTH) + 1)); // Calendar.MONTH is 0-based
alarmSettings.setYear((short) (cal.get(Calendar.YEAR) - 2000));
}
Message alarmMessage = new WithingsMessage(WithingsMessageType.SET_ALARM, alarmSettings);
if (alarm.getSmartWakeup()) {
final Integer interval = alarm.getSmartWakeupInterval();
final int maxInterval = gbDevice.getDeviceCoordinator().getSmartWakeupMaxInterval(gbDevice);
short minutes;
if (interval != null && interval > 0) {
minutes = (short) Math.min(interval, maxInterval);
} else {
// Fall back to a sensible default (20 min) if not configured
minutes = 20;
}
alarmSettings.setSmartWakeupMinutes(minutes);
}
multiAlarmMessage.addDataStructure(alarmSettings);
if (!StringUtils.isEmpty(alarm.getTitle())) {
AlarmName alarmName = new AlarmName(alarm.getTitle());
alarmMessage.addDataStructure(alarmName);
multiAlarmMessage.addDataStructure(alarmName);
}
addSimpleConversationToQueue(alarmMessage);
addSimpleConversationToQueue(new WithingsMessage(WithingsMessageType.SET_ALARM_ENABLED, new AlarmStatus(true)));
}
private short mapRepetitionToWithingsValue(Alarm alarm) {
@@ -732,7 +768,7 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
}
@NonNull
private Message createWorkoutScreenMessage(String workoutType) {
protected Message createWorkoutScreenMessage(String workoutType) {
WithingsActivityType withingsActivityType = WithingsActivityType.fromPrefValue(workoutType);
int code = withingsActivityType.getCode();
Message message = new WithingsMessage(WithingsMessageType.SET_WORKOUT_SCREEN, ExpectedResponse.NONE);
@@ -163,6 +163,55 @@ public enum WithingsActivityType {
}
}
/**
* Returns the workout mode byte used in the WORKOUT_SCREEN_SETTINGS TLV.
* <ul>
* <li>1 = Indoor / pool (no GPS, indoor-specific UI)</li>
* <li>2 = GPS tracking (outdoor with GPS)</li>
* <li>3 = No GPS (outdoor/generic without GPS)</li>
* </ul>
*/
public byte getWorkoutMode() {
switch (this) {
// GPS-tracked outdoor sports
case WALKING:
case RUNNING:
case HIKING:
case SURFING:
case KITESURFING:
case WINDSURFING:
case SKIING:
case SNOWBOARDING:
case RIDING:
return 2; // GPS
// Indoor / pool sports
case SWIMMING:
case CLIMBING:
case ELLIPTICAL:
case WEIGHTLIFTING:
case GYMNASTICS:
case PILATES:
case YOGA:
case DANCING:
case ZUMBA:
case BOXING:
case ICESKATING:
case ROWING:
return 1; // Indoor
// Everything else: no GPS, not specifically indoor
default:
return 3; // NoGPS
}
}
/**
* Returns the flags short for the WORKOUT_SCREEN_SETTINGS TLV.
* Indoor/pool types use 0x0001; all others use 0x0000.
*/
public short getWorkoutFlags() {
return getWorkoutMode() == 1 ? (short) 0x0001 : (short) 0x0000;
}
public static WithingsActivityType fromPrefValue(final String prefValue) {
for (final WithingsActivityType type : values()) {
if (type.name().toLowerCase(Locale.ROOT).equals(prefValue.replace("_", "").toLowerCase(Locale.ROOT))) {
@@ -0,0 +1,171 @@
/* 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.Intent;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.entities.Alarm;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmName;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.AlarmSettings;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
/**
* Handles the response to {@code CMD_GET_MULTI_ALARM} (0x0146 / 326).
*
* <p>Reads alarm TLVs from the watch response, matches them by position with the
* Gadgetbridge alarm database, and updates the DB so the UI reflects the watch's
* current state (enabled/disabled, time, repetition, smart wakeup, title).
*
* <p>This enables two important behaviors:
* <ul>
* <li>One-time alarms auto-disable after firing (the watch clears the enabled bit)</li>
* <li>Alarms toggled on the watch are reflected in Gadgetbridge on next sync</li>
* </ul>
*/
public class GetAlarmHandler extends AbstractResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetAlarmHandler.class);
public GetAlarmHandler(WithingsBaseDeviceSupport support) {
super(support);
}
@Override
public void handleResponse(Message response) {
List<WithingsStructure> data = response.getDataStructures();
if (data == null) {
logger.info("GetAlarmHandler: null data structures in response");
return;
}
// Collect alarm settings and names from the response.
// Each AlarmSettings may be followed by an AlarmName for the same slot.
List<AlarmSettings> watchAlarms = new ArrayList<>();
List<String> watchAlarmNames = new ArrayList<>();
for (WithingsStructure structure : data) {
if (structure instanceof AlarmSettings) {
watchAlarms.add((AlarmSettings) structure);
// Placeholder name until we see an AlarmName TLV
watchAlarmNames.add(null);
} else if (structure instanceof AlarmName) {
// The AlarmName belongs to the most recently seen AlarmSettings
if (!watchAlarmNames.isEmpty()) {
watchAlarmNames.set(watchAlarmNames.size() - 1, ((AlarmName) structure).getName());
}
}
}
logger.info("GetAlarmHandler: watch reported {} alarm(s)", watchAlarms.size());
// Get the current alarms from the GB database (ordered by position)
List<Alarm> dbAlarms = DBHelper.getAlarms(device);
boolean changed = false;
for (int i = 0; i < dbAlarms.size(); i++) {
Alarm dbAlarm = dbAlarms.get(i);
if (i < watchAlarms.size()) {
AlarmSettings watchAlarm = watchAlarms.get(i);
String name = watchAlarmNames.get(i);
boolean watchEnabled = watchAlarm.isEnabled();
int watchRepetition = watchAlarm.getRepetitionMask();
int watchHour = watchAlarm.getHour();
int watchMinute = watchAlarm.getMinute();
int watchSmartWake = watchAlarm.getSmartWakeupMinutes();
// Update enabled state
if (dbAlarm.getEnabled() != watchEnabled) {
logger.info("GetAlarmHandler: alarm {} enabled {} -> {}",
i, dbAlarm.getEnabled(), watchEnabled);
dbAlarm.setEnabled(watchEnabled);
changed = true;
}
// Update time
if (dbAlarm.getHour() != watchHour || dbAlarm.getMinute() != watchMinute) {
logger.info("GetAlarmHandler: alarm {} time {}:{} -> {}:{}",
i, dbAlarm.getHour(), dbAlarm.getMinute(), watchHour, watchMinute);
dbAlarm.setHour(watchHour);
dbAlarm.setMinute(watchMinute);
changed = true;
}
// Update repetition
if (dbAlarm.getRepetition() != watchRepetition) {
logger.info("GetAlarmHandler: alarm {} repetition {} -> {}",
i, dbAlarm.getRepetition(), watchRepetition);
dbAlarm.setRepetition(watchRepetition);
changed = true;
}
// Update smart wakeup
if (watchSmartWake > 0) {
if (!dbAlarm.getSmartWakeup() || !Integer.valueOf(watchSmartWake).equals(dbAlarm.getSmartWakeupInterval())) {
dbAlarm.setSmartWakeup(true);
dbAlarm.setSmartWakeupInterval(watchSmartWake);
changed = true;
}
} else {
if (dbAlarm.getSmartWakeup()) {
dbAlarm.setSmartWakeup(false);
changed = true;
}
}
// Update title
if (name != null && !name.equals(dbAlarm.getTitle())) {
dbAlarm.setTitle(name);
changed = true;
}
if (changed) {
DBHelper.store(dbAlarm);
}
} else {
// More DB alarms than watch alarms - disable any extras
if (dbAlarm.getEnabled()) {
logger.info("GetAlarmHandler: alarm {} not on watch, disabling", i);
dbAlarm.setEnabled(false);
DBHelper.store(dbAlarm);
changed = true;
}
}
}
if (changed) {
// Broadcast to refresh the alarm UI
Intent intent = new Intent(DeviceService.ACTION_SAVE_ALARMS);
LocalBroadcastManager.getInstance(GBApplication.getContext()).sendBroadcast(intent);
logger.info("GetAlarmHandler: alarm database updated, UI refresh broadcast sent");
}
}
}
@@ -0,0 +1,68 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation;
import android.content.SharedPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.GlanceStatus;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
/**
* Handles the response to {@code CMD_GLANCE_GET} (0x097B).
*
* <p>Reads the current quicklook/glance status from the watch and persists it to the
* device-specific {@link SharedPreferences} under {@link #PREF_QUICKLOOK}.
*/
public class GetGlanceHandler extends AbstractResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetGlanceHandler.class);
/** Device-specific shared-prefs key for the quicklook/glance status (boolean). */
public static final String PREF_QUICKLOOK = "withings_scanwatch_quicklook";
public GetGlanceHandler(WithingsBaseDeviceSupport support) {
super(support);
}
@Override
public void handleResponse(Message response) {
List<WithingsStructure> data = response.getDataStructures();
if (data == null || data.isEmpty()) {
logger.warn("GetGlanceHandler: received empty response");
return;
}
for (WithingsStructure structure : data) {
if (structure instanceof GlanceStatus) {
final boolean enabled = ((GlanceStatus) structure).isEnabled();
logger.info("GetGlanceHandler: quicklook/glance = {}", enabled);
final SharedPreferences prefs =
GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
prefs.edit()
.putBoolean(PREF_QUICKLOOK, enabled)
.apply();
}
}
}
}
@@ -0,0 +1,71 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation;
import android.content.SharedPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LuminosityLevel;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
/**
* Handles the response to {@code CMD_GET_LUMINOSITY_LEVEL} (0x0942).
*
* <p>Reads the current brightness mode and level from the watch and persists them to
* the device-specific {@link SharedPreferences}.
*/
public class GetLuminosityHandler extends AbstractResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetLuminosityHandler.class);
public static final String PREF_AUTO_BRIGHTNESS = "withings_scanwatch_auto_brightness";
public static final String PREF_BRIGHTNESS_LEVEL = "withings_scanwatch_brightness_level";
public GetLuminosityHandler(WithingsBaseDeviceSupport support) {
super(support);
}
@Override
public void handleResponse(Message response) {
List<WithingsStructure> data = response.getDataStructures();
if (data == null || data.isEmpty()) {
logger.warn("GetLuminosityHandler: received empty response");
return;
}
for (WithingsStructure structure : data) {
if (structure instanceof LuminosityLevel) {
final LuminosityLevel lum = (LuminosityLevel) structure;
final boolean autoMode = lum.isAutoMode();
final int level = lum.getLevel() & 0xFF;
logger.info("GetLuminosityHandler: auto={}, level={}", autoMode, level);
final SharedPreferences prefs =
GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
prefs.edit()
.putBoolean(PREF_AUTO_BRIGHTNESS, autoMode)
.putString(PREF_BRIGHTNESS_LEVEL, String.valueOf(level))
.apply();
}
}
}
}
@@ -0,0 +1,68 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation;
import android.content.SharedPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.TrackerMoveHands;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
/**
* Handles the response to {@code CMD_TRACKER_MOVE_HANDS_GET} (0x09AC).
*
* <p>Reads the current tracker move-hands status from the watch and persists it to the
* device-specific {@link SharedPreferences} under {@link #PREF_MOVE_HANDS}.
*/
public class GetMoveHandsHandler extends AbstractResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetMoveHandsHandler.class);
/** Device-specific shared-prefs key for the move-hands status (boolean). */
public static final String PREF_MOVE_HANDS = "withings_scanwatch_move_hands";
public GetMoveHandsHandler(WithingsBaseDeviceSupport support) {
super(support);
}
@Override
public void handleResponse(Message response) {
List<WithingsStructure> data = response.getDataStructures();
if (data == null || data.isEmpty()) {
logger.warn("GetMoveHandsHandler: received empty response");
return;
}
for (WithingsStructure structure : data) {
if (structure instanceof TrackerMoveHands) {
final boolean enabled = ((TrackerMoveHands) structure).isEnabled();
logger.info("GetMoveHandsHandler: move-hands = {}", enabled);
final SharedPreferences prefs =
GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
prefs.edit()
.putBoolean(PREF_MOVE_HANDS, enabled)
.apply();
}
}
}
}
@@ -0,0 +1,69 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation;
import android.content.SharedPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.WithingsBaseDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.TrackerWearPos;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.message.Message;
/**
* Handles the response to {@code CMD_GET_TRACKER_WEAR_POS} (0x0150).
*
* <p>Reads the current wear position from the watch and persists it to the
* device-specific {@link SharedPreferences} under
* {@link DeviceSettingsPreferenceConst#PREF_WEARLOCATION} using the standard
* {@code "left"} / {@code "right"} string values.
*/
public class GetWearPosHandler extends AbstractResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(GetWearPosHandler.class);
public GetWearPosHandler(WithingsBaseDeviceSupport support) {
super(support);
}
@Override
public void handleResponse(Message response) {
List<WithingsStructure> data = response.getDataStructures();
if (data == null || data.isEmpty()) {
logger.warn("GetWearPosHandler: received empty response");
return;
}
for (WithingsStructure structure : data) {
if (structure instanceof TrackerWearPos) {
final TrackerWearPos wearPos = (TrackerWearPos) structure;
final String location = wearPos.isLeftWrist() ? "left" : "right";
logger.info("GetWearPosHandler: wear position = {} (raw={})", location, wearPos.getPosition());
final SharedPreferences prefs =
GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
prefs.edit()
.putString(DeviceSettingsPreferenceConst.PREF_WEARLOCATION, location)
.apply();
}
}
}
}
@@ -20,18 +20,24 @@ import java.nio.ByteBuffer;
public class ActivityTarget extends WithingsStructure {
private long targetCount;
public static final int GOAL_TYPE_STEPS = 0;
public static final int GOAL_TYPE_SLEEP = 1;
public static final int GOAL_TYPE_SWIM = 2;
public ActivityTarget(long targetCount) {
this.targetCount = targetCount;
private int goalType;
private int value;
public ActivityTarget(int goalType, int value) {
this.goalType = goalType;
this.value = value;
}
public long getTargetCount() {
return targetCount;
public int getGoalType() {
return goalType;
}
public void setTargetCount(long targetCount) {
this.targetCount = targetCount;
public int getValue() {
return value;
}
@Override
@@ -41,7 +47,8 @@ public class ActivityTarget extends WithingsStructure {
@Override
protected void fillinTypeSpecificData(ByteBuffer rawDataBuffer) {
rawDataBuffer.putLong(targetCount);
rawDataBuffer.putInt(goalType);
rawDataBuffer.putInt(value);
}
@Override
@@ -23,10 +23,17 @@ public class AlarmName extends WithingsStructure {
private String name;
/** No-arg constructor required by {@link DataStructureFactory}. */
public AlarmName() {}
public AlarmName(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public short getLength() {
return (short) ((name != null ? name.getBytes().length : 0) + 1 + HEADER_SIZE);
@@ -37,6 +44,13 @@ public class AlarmName extends WithingsStructure {
addStringAsBytesWithLengthByte(rawDataBuffer, name);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
if (rawDataBuffer.remaining() >= 1) {
name = getNextString(rawDataBuffer);
}
}
@Override
public short getType() {
return WithingsStructureType.ALARM_NAME;
@@ -27,6 +27,39 @@ public class AlarmSettings extends WithingsStructure {
private short year;
private short smartWakeupMinutes;
/** No-arg constructor required by {@link DataStructureFactory}. */
public AlarmSettings() {}
/**
* Returns whether this alarm is enabled (bit 7 of dayOfWeek flags).
*/
public boolean isEnabled() {
return (dayOfWeek & 0x80) != 0;
}
/**
* Returns the repetition bitmask in Gadgetbridge format (MON=1..SUN=64),
* extracted from the Withings flags byte (bits 0-6).
* Returns 0 (ALARM_ONCE) if no day bits are set (one-time alarm).
*/
public int getRepetitionMask() {
int gbRepetition = 0;
// Withings: bit0=Sun, bit1=Mon, bit2=Tue, bit3=Wed, bit4=Thu, bit5=Fri, bit6=Sat
// GB: SUN=64, MON=1, TUE=2, WED=4, THU=8, FRI=16, SAT=32
if ((dayOfWeek & 0x01) != 0) gbRepetition |= 64; // Sun
if ((dayOfWeek & 0x02) != 0) gbRepetition |= 1; // Mon
if ((dayOfWeek & 0x04) != 0) gbRepetition |= 2; // Tue
if ((dayOfWeek & 0x08) != 0) gbRepetition |= 4; // Wed
if ((dayOfWeek & 0x10) != 0) gbRepetition |= 8; // Thu
if ((dayOfWeek & 0x20) != 0) gbRepetition |= 16; // Fri
if ((dayOfWeek & 0x40) != 0) gbRepetition |= 32; // Sat
return gbRepetition;
}
public short getSmartWakeupMinutes() {
return smartWakeupMinutes;
}
public short getHour() {
return hour;
}
@@ -104,6 +137,19 @@ public class AlarmSettings extends WithingsStructure {
return WithingsStructureType.ALARM;
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
if (rawDataBuffer.remaining() >= 7) {
hour = (short) (rawDataBuffer.get() & 0xFF);
minute = (short) (rawDataBuffer.get() & 0xFF);
dayOfWeek = (short) (rawDataBuffer.get() & 0xFF);
dayOfMonth = (short) (rawDataBuffer.get() & 0xFF);
month = (short) (rawDataBuffer.get() & 0xFF);
year = (short) (rawDataBuffer.get() & 0xFF);
smartWakeupMinutes = (short) (rawDataBuffer.get() & 0xFF);
}
}
@Override
public boolean withEndOfMessage() {
return true;
@@ -140,6 +140,24 @@ public class DataStructureFactory {
case WithingsStructureType.LOCAL_NOTIFICATION:
structure = new LocalNotification();
break;
case WithingsStructureType.GLANCE_STATUS:
structure = new GlanceStatus();
break;
case WithingsStructureType.ALARM:
structure = new AlarmSettings();
break;
case WithingsStructureType.ALARM_NAME:
structure = new AlarmName();
break;
case WithingsStructureType.LUMINOSITY_LEVEL:
structure = new LuminosityLevel();
break;
case WithingsStructureType.TRACKER_MOVE_HANDS:
structure = new TrackerMoveHands();
break;
case WithingsStructureType.TRACKER_WEAR_POS:
structure = new TrackerWearPos();
break;
default:
structure = null;
logger.info("Received yet unknown structure type: " + structureTypeFromResponse);
@@ -30,16 +30,19 @@ 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_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_ECG_MEAS} (0x000F) - ECG measurement enabled</li>
* <li>{@link #TAG_IRREGULAR_HR}(0x0013) - Irregular heart rate detection</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 during ECG activation (purpose unknown)</li>
* <li>{@link #TAG_0x0058} (0x0058) - Present during ECG activation (purpose unknown)</li>
* <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_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>
* <li>{@link #TAG_0x0058} (0x0058) - Present when any health feature is active (purpose unknown)</li>
* </ul>
*/
public class FeatureTagDeprecated extends WithingsStructure {
@@ -48,23 +51,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). */
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;
/** ECG measurement feature enabled. */
/** 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;
/** High heart rate notification. */
public static final short TAG_HIGH_HR = (short) 0x0014;
/** Low heart rate notification. */
public static final short TAG_LOW_HR = (short) 0x0016;
/** Unknown; observed during ECG activation (always-active). */
/** Unknown; present when any health feature is active. */
public static final short TAG_0x0035 = 0x0035;
/** Unknown; observed during ECG activation (always-active). */
/** Unknown; present when any health feature is active. */
public static final short TAG_0x0058 = 0x0058;
// ---- Payload ----
@@ -0,0 +1,70 @@
/* 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;
/**
* Quicklook / glance status (raise-to-wake).
*
* <p>TLV type {@code 0x097A} (2426), 1-byte payload:
* <ul>
* <li>{@code 0} = disabled</li>
* <li>{@code 1} = enabled</li>
* </ul>
*/
public class GlanceStatus extends WithingsStructure {
private boolean enabled;
/** No-arg constructor required by {@link DataStructureFactory}. */
public GlanceStatus() {}
public GlanceStatus(boolean enabled) {
this.enabled = enabled;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
@Override
public short getLength() {
return 5; // 4 header + 1 data
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.put(enabled ? (byte) 1 : (byte) 0);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
if (rawDataBuffer.remaining() >= 1) {
enabled = rawDataBuffer.get() != 0;
}
}
@Override
public short getType() {
return WithingsStructureType.GLANCE_STATUS;
}
}
@@ -20,10 +20,18 @@ import java.nio.ByteBuffer;
public class ImageMetaData extends WithingsStructure {
private byte unknown = 0x00;
private byte index = 0x00;
private byte width;
private byte height;
public byte getIndex() {
return index;
}
public void setIndex(byte index) {
this.index = index;
}
public byte getWidth() {
return width;
}
@@ -47,14 +55,14 @@ public class ImageMetaData extends WithingsStructure {
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.put(unknown);
buffer.put(index);
buffer.put(width);
buffer.put(height);
}
@Override
public void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
unknown = rawDataBuffer.get();
index = rawDataBuffer.get();
width = rawDataBuffer.get();
height = rawDataBuffer.get();
}
@@ -31,10 +31,10 @@ import java.nio.ByteBuffer;
*
* <p>Known notification slot IDs observed from HCI captures:
* <ul>
* <li>{@link #NOTIF_PPG_AFIB} (1) - AFib detection alert</li>
* <li>{@link #NOTIF_ECG} (2) - ECG-related notification (slot seen as disabled)</li>
* <li>{@link #NOTIF_UNKNOWN_3} (3) - Unknown (always seen as disabled)</li>
* <li>{@link #NOTIF_HIGH_LOW_HR} (4) - High/low heart rate alert (enabled during ECG setup)</li>
* <li>{@link #NOTIF_PPG_AFIB} (1) - AFib daytime detection alert</li>
* <li>{@link #NOTIF_HIGH_HR} (2) - High heart rate alert</li>
* <li>{@link #NOTIF_LOW_HR} (3) - Low heart rate alert</li>
* <li>{@link #NOTIF_SLOT_4} (4) - Purpose unknown (seen disabled unless ECG active)</li>
* <li>{@link #NOTIF_PPG_AFIB_NIGHT} (5) - Night-time AFib detection alert</li>
* </ul>
*
@@ -45,14 +45,14 @@ public class LocalNotification extends WithingsStructure {
// ---- Notification slot identifiers ----
/** AFib detection alert. */
/** AFib daytime detection alert. */
public static final byte NOTIF_PPG_AFIB = 1;
/** ECG notification slot. */
public static final byte NOTIF_ECG = 2;
/** Unknown notification slot (appears always disabled). */
public static final byte NOTIF_UNKNOWN_3 = 3;
/** High/low heart-rate alert. */
public static final byte NOTIF_HIGH_LOW_HR = 4;
/** High heart rate alert. */
public static final byte NOTIF_HIGH_HR = 2;
/** Low heart rate alert. */
public static final byte NOTIF_LOW_HR = 3;
/** Purpose unknown (observed disabled unless ECG active). */
public static final byte NOTIF_SLOT_4 = 4;
/** Night-time AFib detection alert. */
public static final byte NOTIF_PPG_AFIB_NIGHT = 5;
@@ -0,0 +1,81 @@
/* 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;
/**
* Screen luminosity mode and level.
*
* <p>TLV type {@code 0x0937} (2359), 2-byte payload:
* <ul>
* <li>byte 0: mode - {@code 0} = auto, {@code 1} = manual</li>
* <li>byte 1: level - {@code 0}-{@code 100} (only meaningful in manual mode)</li>
* </ul>
*/
public class LuminosityLevel extends WithingsStructure {
public static final byte MODE_AUTO = 0;
public static final byte MODE_MANUAL = 1;
private byte mode;
private byte level;
/** No-arg constructor required by {@link DataStructureFactory}. */
public LuminosityLevel() {}
public LuminosityLevel(byte mode, byte level) {
this.mode = mode;
this.level = level;
}
public byte getMode() {
return mode;
}
public byte getLevel() {
return level;
}
public boolean isAutoMode() {
return mode == MODE_AUTO;
}
@Override
public short getLength() {
return 6; // 4 header + 2 data
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.put(mode);
buffer.put(level);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
if (rawDataBuffer.remaining() >= 2) {
mode = rawDataBuffer.get();
level = rawDataBuffer.get();
}
}
@Override
public short getType() {
return WithingsStructureType.LUMINOSITY_LEVEL;
}
}
@@ -22,7 +22,7 @@ public class ScreenSettings extends WithingsStructure {
private int id;
private int userId = 123456;
private int userId = 0;
private int yetUnknown1 = 0;
private int yetUnknown2 = 0;
private byte idOnDevice;
@@ -0,0 +1,70 @@
/* 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;
/**
* Tracker move-hands status (move hands to 10:10 when the screen turns on).
*
* <p>TLV type {@code 0x09BB} (2491), 1-byte payload:
* <ul>
* <li>{@code 0} = disabled</li>
* <li>{@code 1} = enabled</li>
* </ul>
*/
public class TrackerMoveHands extends WithingsStructure {
private boolean enabled;
/** No-arg constructor required by {@link DataStructureFactory}. */
public TrackerMoveHands() {}
public TrackerMoveHands(boolean enabled) {
this.enabled = enabled;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
@Override
public short getLength() {
return 5; // 4 header + 1 data
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.put(enabled ? (byte) 1 : (byte) 0);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
if (rawDataBuffer.remaining() >= 1) {
enabled = rawDataBuffer.get() != 0;
}
}
@Override
public short getType() {
return WithingsStructureType.TRACKER_MOVE_HANDS;
}
}
@@ -0,0 +1,82 @@
/* 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;
/**
* Tracker wear position (which wrist the watch is worn on).
*
* <p>TLV type {@code 0x012F} (303), 1-byte payload:
* <ul>
* <li>{@code 0} = not set</li>
* <li>{@code 1} = hip</li>
* <li>{@code 2} = left wrist</li>
* <li>{@code 3} = right wrist</li>
* </ul>
*/
public class TrackerWearPos extends WithingsStructure {
public static final byte POS_LEFT_WRIST = 2;
public static final byte POS_RIGHT_WRIST = 3;
private byte position;
/** No-arg constructor required by {@link DataStructureFactory}. */
public TrackerWearPos() {}
public TrackerWearPos(byte position) {
this.position = position;
}
public byte getPosition() {
return position;
}
public void setPosition(byte position) {
this.position = position;
}
/**
* Returns {@code true} if the position is left wrist.
*/
public boolean isLeftWrist() {
return position == POS_LEFT_WRIST;
}
@Override
public short getLength() {
return 5; // 4 header + 1 data
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.put(position);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer rawDataBuffer) {
if (rawDataBuffer.remaining() >= 1) {
position = rawDataBuffer.get();
}
}
@Override
public short getType() {
return WithingsStructureType.TRACKER_WEAR_POS;
}
}
@@ -80,5 +80,17 @@ public class WithingsStructureType {
/** Local (on-watch) notification slot configuration (type 0x09A8 = 2472). */
public static final short LOCAL_NOTIFICATION = (short) 0x09A8; // 2472
/** Quicklook / glance (raise-to-wake) status (type 0x097A = 2426). */
public static final short GLANCE_STATUS = (short) 0x097A; // 2426
/** Screen luminosity mode + level (type 0x0937 = 2359), 2-byte payload: [mode][level]. */
public static final short LUMINOSITY_LEVEL = (short) 0x0937; // 2359
/** Tracker move-hands status (type 0x09BB = 2491), 1-byte payload: 0=off, 1=on. */
public static final short TRACKER_MOVE_HANDS = (short) 0x09BB; // 2491
/** Tracker wear position (type 0x012F = 303), 1-byte payload: 2=left wrist, 3=right wrist. */
public static final short TRACKER_WEAR_POS = 303; // 0x012F
private WithingsStructureType() {}
}
@@ -48,6 +48,7 @@ public final class WithingsMessageType {
public static final short GET_ALARM_SETTINGS = 298;
public static final short SET_ALARM = 325;
public static final short GET_ALARM = 293;
public static final short GET_MULTI_ALARM = 326;
public static final short GET_ALARM_ENABLED = 2330;
public static final short SET_ALARM_ENABLED = 2331;
public static final short GET_ANCS_STATUS = 2353;
@@ -85,5 +86,25 @@ public final class WithingsMessageType {
*/
public static final short SET_LOCAL_NOTIFICATIONS = (short) 0x0990; // 2448
/** 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). */
public static final short GLANCE_SET = (short) 0x0971; // 2417
/** Set screen luminosity mode & level (cmd 0x0941). */
public static final short SET_LUMINOSITY_LEVEL = (short) 0x0941; // 2369
/** Get screen luminosity mode & level (cmd 0x0942). */
public static final short GET_LUMINOSITY_LEVEL = (short) 0x0942; // 2370
/** Set tracker move-hands (move hands to 10:10 when screen turns on) (cmd 0x09AB). */
public static final short SET_TRACKER_MOVE_HANDS = (short) 0x09AB; // 2475
/** Get tracker move-hands status (cmd 0x09AC). */
public static final short GET_TRACKER_MOVE_HANDS = (short) 0x09AC; // 2476
/** Set tracker wear position (left/right wrist) (cmd 0x014F). */
public static final short SET_TRACKER_WEAR_POS = 335; // 0x014F
/** Get tracker wear position (cmd 0x0150). */
public static final short GET_TRACKER_WEAR_POS = 336; // 0x0150
private WithingsMessageType() {}
}
@@ -116,6 +116,15 @@
android:inputType="number"
android:hint="@string/alarm_smart_wakeup_interval_default" />
<TextView
android:id="@+id/tv_smart_wakeup_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:visibility="gone" />
<TimePicker
android:id="@+id/time_picker"
android:layout_width="match_parent"
@@ -5,24 +5,64 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Mode toggle: App dial vs Watch crown -->
<RadioGroup
android:id="@+id/withings_calibration_mode_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingTop="8dp"
android:paddingBottom="4dp">
<RadioButton
android:id="@+id/withings_calibration_mode_app"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/withings_calibration_mode_app"
android:checked="true" />
<RadioButton
android:id="@+id/withings_calibration_mode_crown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:text="@string/withings_calibration_mode_crown" />
</RadioGroup>
<TextView
android:id="@+id/withings_calibration_textview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/withings_calibration_mode_group"
android:padding="30dp"
android:text="@string/withings_calibration_text_hours"
android:textAppearance="?android:attr/textAppearanceLarge" />
<!-- App dial mode: RotaryControl -->
<nodomain.freeyourgadget.gadgetbridge.devices.withingssteelhr.RotaryControl
android:id="@+id/rotary_control"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/withings_calibration_textview"
android:layout_above="@id/withings_calibration_button_previous"
android:padding="30dp"
rotaryControl:controlpoint_color="@color/accent"
rotaryControl:controlpoint_size="20dp"
rotaryControl:line_color="@color/design_default_color_on_secondary"
rotaryControl:line_thickness="10dp" />
<!-- Crown mode: Confirm button (hidden by default) -->
<Button
android:id="@+id/withings_calibration_button_confirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:padding="24dp"
android:text="@string/withings_calibration_confirm"
android:textSize="18sp"
android:visibility="gone" />
<Button
android:id="@+id/withings_calibration_button_previous"
android:layout_width="wrap_content"
@@ -55,4 +95,4 @@
android:padding="16dp"
android:text="@string/ok" />
</RelativeLayout>
</RelativeLayout>
+11
View File
@@ -1914,6 +1914,17 @@
<item>9</item>
</string-array>
<string-array name="pref_withings_scanwatch_respiratory_scan_entries">
<item>@string/withings_scanwatch_pref_respiratory_scan_off</item>
<item>@string/withings_scanwatch_pref_respiratory_scan_automatic</item>
<item>@string/withings_scanwatch_pref_respiratory_scan_always</item>
</string-array>
<string-array name="pref_withings_scanwatch_respiratory_scan_values">
<item>off</item>
<item>automatic</item>
<item>always</item>
</string-array>
<string-array name="pref_neo_display_items">
<item>@string/menuitem_pai</item>
<item>@string/menuitem_dnd</item>
+30 -3
View File
@@ -3734,7 +3734,13 @@
<string name="loyalty_cards_syncing">Syncing %d loyalty cards to device</string>
<string name="withings_calibration_text_hours">Please use the dial below to align the hour hand to the 12.</string>
<string name="withings_calibration_text_minutes">Now use the dial to align the minute hand to the 12.</string>
<string name="withings_calibration_text_activity_target">Finally align the activity hand to 100%. Please be aware that this hand only moves clockwise.</string>
<string name="withings_calibration_text_activity_target">Finally align the activity hand to 100%.</string>
<string name="withings_calibration_crown_text_hours">Use the crown on your watch to move the hour hand to the 12.</string>
<string name="withings_calibration_crown_text_minutes">Now use the crown to move the minute hand to the 12.</string>
<string name="withings_calibration_crown_text_activity_target">Finally use the crown to move the activity hand to 100%.</string>
<string name="withings_calibration_mode_app">App dial</string>
<string name="withings_calibration_mode_crown">Watch crown</string>
<string name="withings_calibration_confirm">Confirm</string>
<string name="withings_bt_calibration_previous">Previous</string>
<string name="withings_bt_calibration_next">Next</string>
<string name="withings_scanwatch_pref_screens_title">Watch screens</string>
@@ -3756,8 +3762,29 @@
<!-- ECG and AFib health feature settings -->
<string name="withings_scanwatch_pref_ecg_title">ECG Measurement</string>
<string name="withings_scanwatch_pref_ecg_summary">Enable ECG measurement feature on the watch</string>
<string name="withings_scanwatch_pref_afib_title">AFib Detection</string>
<string name="withings_scanwatch_pref_afib_summary">Enable atrial fibrillation detection and night AFib monitoring</string>
<string name="withings_scanwatch_pref_respiratory_scan_title">Respiratory Scan</string>
<string name="withings_scanwatch_pref_respiratory_scan_off">Off</string>
<string name="withings_scanwatch_pref_respiratory_scan_automatic">Automatic (scans some nights, minimal battery impact)</string>
<string name="withings_scanwatch_pref_respiratory_scan_always">Always (scans every night, higher battery usage)</string>
<string name="withings_scanwatch_pref_afib_day_title">AFib Detection (Daytime)</string>
<string name="withings_scanwatch_pref_afib_day_summary">Enable daytime atrial fibrillation detection</string>
<string name="withings_scanwatch_pref_afib_night_title">AFib Detection (Night)</string>
<string name="withings_scanwatch_pref_afib_night_summary">Enable night-time atrial fibrillation detection</string>
<string name="withings_scanwatch_alarm_smart_wakeup_description">The watch will find the best moment to wake you up between the smart wakeup window start and your alarm time, based on your sleep cycle. Set the window to 0 to wake up exactly at the alarm time.</string>
<!-- Quicklook / glance (raise-to-wake) -->
<string name="withings_scanwatch_pref_quicklook_title">Quicklook</string>
<string name="withings_scanwatch_pref_quicklook_summary">Show time when you raise your wrist</string>
<!-- Brightness / luminosity -->
<string name="withings_scanwatch_pref_auto_brightness_title">Auto brightness</string>
<string name="withings_scanwatch_pref_auto_brightness_summary">Automatically adjust screen brightness</string>
<string name="withings_scanwatch_pref_brightness_level_title">Brightness level</string>
<string name="withings_scanwatch_pref_brightness_level_summary">Manual brightness level (0-100)</string>
<!-- Move hands (move hands to 10:10 when screen turns on) -->
<string name="withings_scanwatch_pref_move_hands_title">Move hands</string>
<string name="withings_scanwatch_pref_move_hands_summary">Move watch hands to 10:10 when the screen turns on to avoid blocking the display</string>
<!-- Shortcut (long-press crown) settings -->
<string name="withings_scanwatch_pref_shortcut_title">Long-press crown shortcut</string>
@@ -52,13 +52,70 @@
android:summary="@string/withings_scanwatch_pref_ecg_summary"
android:title="@string/withings_scanwatch_pref_ecg_title" />
<!-- AFib detection -->
<!-- Respiratory scan (SpO2 during sleep) -->
<ListPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="off"
android:entries="@array/pref_withings_scanwatch_respiratory_scan_entries"
android:entryValues="@array/pref_withings_scanwatch_respiratory_scan_values"
android:key="withings_scanwatch_respiratory_scan"
android:persistent="true"
android:title="@string/withings_scanwatch_pref_respiratory_scan_title" />
<!-- AFib daytime detection -->
<SwitchPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="false"
android:key="withings_scanwatch_afib_enabled"
android:key="withings_scanwatch_afib_day_enabled"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_afib_summary"
android:title="@string/withings_scanwatch_pref_afib_title" />
android:summary="@string/withings_scanwatch_pref_afib_day_summary"
android:title="@string/withings_scanwatch_pref_afib_day_title" />
<!-- AFib night detection -->
<SwitchPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="false"
android:key="withings_scanwatch_afib_night_enabled"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_afib_night_summary"
android:title="@string/withings_scanwatch_pref_afib_night_title" />
<!-- Quicklook / glance (raise-to-wake) -->
<SwitchPreference
android:icon="@drawable/ic_always_on_display"
android:defaultValue="false"
android:key="withings_scanwatch_quicklook"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_quicklook_summary"
android:title="@string/withings_scanwatch_pref_quicklook_title" />
<!-- Auto brightness toggle -->
<SwitchPreference
android:icon="@drawable/ic_brightness_2"
android:defaultValue="true"
android:key="withings_scanwatch_auto_brightness"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_auto_brightness_summary"
android:title="@string/withings_scanwatch_pref_auto_brightness_title" />
<!-- Manual brightness level (only meaningful when auto brightness is OFF) -->
<EditTextPreference
android:icon="@drawable/ic_brightness_2"
android:defaultValue="100"
android:inputType="number"
android:key="withings_scanwatch_brightness_level"
android:maxLength="3"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_brightness_level_summary"
android:title="@string/withings_scanwatch_pref_brightness_level_title" />
<!-- Move hands to 10:10 when screen turns on -->
<SwitchPreference
android:icon="@drawable/ic_sensor_calibration"
android:defaultValue="false"
android:key="withings_scanwatch_move_hands"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_move_hands_summary"
android:title="@string/withings_scanwatch_pref_move_hands_title" />
</androidx.preference.PreferenceScreen>