feat(withings,scanwatch): Add ECG and AFib support

This commit is contained in:
d3vv3
2026-03-28 13:15:06 +01:00
parent d74e2c53ee
commit c55f0fe1af
10 changed files with 457 additions and 0 deletions
@@ -32,11 +32,15 @@ public class WithingsScanwatchSettingsCustomizer implements DeviceSpecificSettin
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";
@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);
final ListPreference shortcutPref = handler.findPreference(PREF_SHORTCUT_ACTION);
if (shortcutPref != null) {
@@ -36,6 +36,9 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.With
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.WithingsUUIDs;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.conversation.GetShortcutHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.EndOfTransmission;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.FeatureTagDeprecated;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.FeatureTagsUserId;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.LocalNotification;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ScreenSettings;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ShortcutAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsScreenId;
@@ -67,6 +70,11 @@ 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";
@Override
protected void addExtraSyncCommands() {
addSimpleConversationToQueue(
@@ -100,9 +108,89 @@ public class WithingsScanwatchDeviceSupport extends WithingsBaseDeviceSupport {
sendQueue();
return true;
}
if (PREF_ECG_ENABLED.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean enabled = prefs.getBoolean(PREF_ECG_ENABLED, false);
clearQueue();
addFeatureTagsCommand(enabled, prefs.getBoolean(PREF_AFIB_ENABLED, false));
addLocalNotificationsCommand(enabled, prefs.getBoolean(PREF_AFIB_ENABLED, false));
sendQueue();
return true;
}
if (PREF_AFIB_ENABLED.equals(config)) {
final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress());
final boolean afibEnabled = prefs.getBoolean(PREF_AFIB_ENABLED, false);
clearQueue();
addFeatureTagsCommand(prefs.getBoolean(PREF_ECG_ENABLED, false), afibEnabled);
addLocalNotificationsCommand(prefs.getBoolean(PREF_ECG_ENABLED, false), afibEnabled);
sendQueue();
return true;
}
return false;
}
/**
* 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.
*/
private void addFeatureTagsCommand(final boolean ecgEnabled, final boolean afibEnabled) {
final WithingsMessage msg = new WithingsMessage(WithingsMessageType.SET_FEATURE_TAGS_DEPRECATED);
msg.addDataStructure(new FeatureTagsUserId()); // userId = 0
if (ecgEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_ECG_TERMS));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_ECG_MEAS));
}
if (afibEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_EXTRA));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_AFIB_NIGHT));
}
if (ecgEnabled || afibEnabled) {
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0035));
msg.addDataStructure(new FeatureTagDeprecated(FeatureTagDeprecated.TAG_0x0058));
}
msg.addDataStructure(new EndOfTransmission());
addSimpleConversationToQueue(msg);
}
/**
* Queues a {@code CMD_LOCAL_NOTIFICATIONS_CONFIG_SET} (0x0990) message configuring the
* five on-watch notification slots.
*
* <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>
* </ol>
*/
private void addLocalNotificationsCommand(final boolean ecgEnabled, final boolean afibEnabled) {
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,
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));
msg.addDataStructure(new EndOfTransmission());
addSimpleConversationToQueue(msg);
}
/**
* Builds the Scanwatch screen list from the user's drag-sort preference.
*
@@ -131,6 +131,15 @@ public class DataStructureFactory {
case WithingsStructureType.SHORTCUT_ACTION:
structure = new ShortcutAction();
break;
case WithingsStructureType.FEATURE_TAG_DEPRECATED:
structure = new FeatureTagDeprecated();
break;
case WithingsStructureType.FEATURE_TAGS_USER_ID:
structure = new FeatureTagsUserId();
break;
case WithingsStructureType.LOCAL_NOTIFICATION:
structure = new LocalNotification();
break;
default:
structure = null;
logger.info("Received yet unknown structure type: " + structureTypeFromResponse);
@@ -0,0 +1,131 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures;
import java.nio.ByteBuffer;
/**
* Encodes one entry in the feature-tags-deprecated list (TLV type {@code 0x099C}).
*
* <p>Wire format: 2-byte type + 2-byte length (10) + 10-byte payload:
* <pre>
* id (uint16 BE) - feature tag identifier
* startTime (uint32 BE) - Unix timestamp; 0 = always active
* endTime (uint32 BE) - Unix timestamp; 0 = no expiry
* </pre>
*
* <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>
* </ul>
*/
public class FeatureTagDeprecated extends WithingsStructure {
// ---- Known feature tag identifiers ----
/** ECG terms &amp; conditions accepted by the user. */
public static final short TAG_ECG_TERMS = 0x0004;
/** 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. */
public static final short TAG_ECG_MEAS = (short) 0x000F;
/** 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). */
public static final short TAG_0x0035 = 0x0035;
/** Unknown; observed during ECG activation (always-active). */
public static final short TAG_0x0058 = 0x0058;
// ---- Payload ----
private short tagId;
private int startTime; // 0 = always active
private int endTime; // 0 = no expiry
/** No-arg constructor required by {@link DataStructureFactory}. */
public FeatureTagDeprecated() {}
/**
* Creates a feature tag that is always active (start/end = 0).
*
* @param tagId one of the {@code TAG_*} constants
*/
public FeatureTagDeprecated(short tagId) {
this(tagId, 0, 0);
}
/**
* Creates a feature tag with an explicit activation window.
*
* @param tagId one of the {@code TAG_*} constants
* @param startTime Unix epoch seconds; 0 = always active
* @param endTime Unix epoch seconds; 0 = no expiry
*/
public FeatureTagDeprecated(short tagId, int startTime, int endTime) {
this.tagId = tagId;
this.startTime = startTime;
this.endTime = endTime;
}
public short getTagId() { return tagId; }
public int getStartTime() { return startTime; }
public int getEndTime() { return endTime; }
@Override
public short getLength() {
// 4-byte TLV header + 10-byte payload (u16 + u32 + u32)
return 14;
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.putShort(tagId);
buffer.putInt(startTime);
buffer.putInt(endTime);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer buffer) {
if (buffer.remaining() >= 10) {
tagId = buffer.getShort();
startTime = buffer.getInt();
endTime = buffer.getInt();
}
}
@Override
public short getType() {
return WithingsStructureType.FEATURE_TAG_DEPRECATED;
}
}
@@ -0,0 +1,63 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures;
import java.nio.ByteBuffer;
/**
* Encodes the user-ID header TLV (type {@code 0x0145}) that precedes the feature-tag list in
* {@code CMD_FEATURE_TAGS_SET_DEPRECATED_V2} (0x0987) messages.
*
* <p>Wire format: 2-byte type + 2-byte length (4) + 4-byte uint32 user-ID (always {@code 0} in
* captures - may be a Withings account ID that Gadgetbridge does not have).
*/
public class FeatureTagsUserId extends WithingsStructure {
private int userId;
/** Constructs with userId = 0 (Gadgetbridge does not have a Withings account ID). */
public FeatureTagsUserId() {
this.userId = 0;
}
public FeatureTagsUserId(int userId) {
this.userId = userId;
}
@Override
public short getLength() {
// 4-byte header + 4-byte uint32
return 8;
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.putInt(userId);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer buffer) {
if (buffer.remaining() >= 4) {
userId = buffer.getInt();
}
}
@Override
public short getType() {
return WithingsStructureType.FEATURE_TAGS_USER_ID;
}
}
@@ -0,0 +1,112 @@
/* Copyright (C) 2024 Gadgetbridge contributors
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures;
import java.nio.ByteBuffer;
/**
* Encodes one on-device notification slot configuration (TLV type {@code 0x09A8}).
*
* <p>Wire format: 2-byte type + 2-byte length (5) + 5-byte payload:
* <pre>
* pad (uint16 BE) - always 0x0000
* pad (uint8) - always 0x00
* notifId (uint8) - notification slot identifier (see {@code NOTIF_*} constants)
* status (uint8) - 0x00 = disabled, 0x01 = enabled
* </pre>
*
* <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_NIGHT} (5) - Night-time AFib detection alert</li>
* </ul>
*
* <p>The full 5-slot configuration must always be sent together when using
* {@code CMD_LOCAL_NOTIFICATIONS_CONFIG_SET} (0x0990).
*/
public class LocalNotification extends WithingsStructure {
// ---- Notification slot identifiers ----
/** AFib 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;
/** Night-time AFib detection alert. */
public static final byte NOTIF_PPG_AFIB_NIGHT = 5;
// ---- Status values ----
public static final byte STATUS_DISABLED = 0x00;
public static final byte STATUS_ENABLED = 0x01;
// ---- Payload ----
private byte notifId;
private byte status;
/** No-arg constructor required by {@link DataStructureFactory}. */
public LocalNotification() {}
/**
* @param notifId one of the {@code NOTIF_*} constants
* @param status {@link #STATUS_ENABLED} or {@link #STATUS_DISABLED}
*/
public LocalNotification(byte notifId, byte status) {
this.notifId = notifId;
this.status = status;
}
public byte getNotifId() { return notifId; }
public byte getStatus() { return status; }
@Override
public short getLength() {
// 4-byte TLV header + 5-byte payload
return 9;
}
@Override
protected void fillinTypeSpecificData(ByteBuffer buffer) {
buffer.putShort((short) 0x0000); // pad
buffer.put((byte) 0x00); // pad
buffer.put(notifId);
buffer.put(status);
}
@Override
protected void fillFromRawDataAsBuffer(ByteBuffer buffer) {
if (buffer.remaining() >= 5) {
buffer.getShort(); // skip pad
buffer.get(); // skip pad
notifId = buffer.get();
status = buffer.get();
}
}
@Override
public short getType() {
return WithingsStructureType.LOCAL_NOTIFICATION;
}
}
@@ -71,5 +71,14 @@ public class WithingsStructureType {
/** Long-press crown shortcut action (type 0x09A1 = 2465). */
public static final short SHORTCUT_ACTION = (short) 0x09A1; // 2465
/** One entry in the feature-tags-deprecated list (type 0x099C = 2460). */
public static final short FEATURE_TAG_DEPRECATED = (short) 0x099C; // 2460
/** User-ID header preceding feature-tag lists in CMD_FEATURE_TAGS_SET_DEPRECATED_V2 (type 0x0145 = 325). */
public static final short FEATURE_TAGS_USER_ID = 0x0145; // 325
/** Local (on-watch) notification slot configuration (type 0x09A8 = 2472). */
public static final short LOCAL_NOTIFICATION = (short) 0x09A8; // 2472
private WithingsStructureType() {}
}
@@ -69,5 +69,21 @@ public final class WithingsMessageType {
/** Set the long-press crown shortcut action (cmd 0x0989). */
public static final short SET_SHORTCUT = (short) 0x0989; // 2441
/**
* Set feature-tags-deprecated list (cmd 0x0987).
* Enables/disables ECG, AFib detection and related health features on the watch.
* The message body contains a {@code FeatureTagsUserId} header followed by one or more
* {@code FeatureTagDeprecated} TLVs and a final {@code EndOfTransmission}.
*/
public static final short SET_FEATURE_TAGS_DEPRECATED = (short) 0x0987; // 2439
/**
* Set local notification slot configuration (cmd 0x0990).
* Configures which on-watch health alerts (AFib, high/low HR, etc.) are enabled.
* The message body contains five {@code LocalNotification} TLVs (one per slot) and a final
* {@code EndOfTransmission}.
*/
public static final short SET_LOCAL_NOTIFICATIONS = (short) 0x0990; // 2448
private WithingsMessageType() {}
}
+6
View File
@@ -3753,6 +3753,12 @@
<string name="withings_scanwatch_screen_breathe">Breathe</string>
<string name="withings_scanwatch_screen_clock">Clock</string>
<!-- 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>
<!-- Shortcut (long-press crown) settings -->
<string name="withings_scanwatch_pref_shortcut_title">Long-press crown shortcut</string>
<string name="withings_scanwatch_pref_shortcut_summary">Action launched by holding the crown button</string>
@@ -34,6 +34,7 @@
<!-- Long-press crown shortcut -->
<ListPreference
android:icon="@drawable/ic_shortcut"
android:defaultValue="0"
android:entries="@array/pref_withings_scanwatch_shortcut_entries"
android:entryValues="@array/pref_withings_scanwatch_shortcut_values"
@@ -42,4 +43,22 @@
android:summary="@string/withings_scanwatch_pref_shortcut_summary"
android:title="@string/withings_scanwatch_pref_shortcut_title" />
<!-- ECG measurement feature -->
<SwitchPreference
android:icon="@drawable/ic_ecg"
android:defaultValue="false"
android:key="withings_scanwatch_ecg_enabled"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_ecg_summary"
android:title="@string/withings_scanwatch_pref_ecg_title" />
<!-- AFib detection -->
<SwitchPreference
android:icon="@drawable/ic_heartrate"
android:defaultValue="false"
android:key="withings_scanwatch_afib_enabled"
android:persistent="true"
android:summary="@string/withings_scanwatch_pref_afib_summary"
android:title="@string/withings_scanwatch_pref_afib_title" />
</androidx.preference.PreferenceScreen>