docs(withings,scanwatch): Collected sync process

This commit is contained in:
d3vv3
2026-04-05 23:27:49 +02:00
parent 21787b9e30
commit 9d40effacd
3 changed files with 35 additions and 0 deletions
@@ -363,6 +363,10 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
message.addDataStructure(new TypeVersion());
addSimpleConversationToQueue(message, activitySampleHandler);
// Official-app-like stored-measure sync is split into two pieces:
// 1) queue polling for signal types 0x0001/0x0004/0x0005, always starting from cursor 0
// 2) a separate ECG discovery flow that enumerates stored ECG record keys before fetching
// waveforms. Mixed 0x0004 pages can still surface ECG metadata, so both paths cooperate.
message = new WithingsMessage(WithingsMessageType.GET_STORED_MEASURE_SIGNAL, ExpectedResponse.EOT);
message.addDataStructure(new StoredSignalMeta(0x0001, 0));
addSimpleConversationToQueue(message, new StoredMeasureSignalHandler(this, gbDevice, 0x0001));
@@ -813,6 +817,8 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
Message deleteMessage = new WithingsMessage(WithingsMessageType.DELETE_STORED_MEASURE_SIGNAL, ExpectedResponse.SIMPLE);
// Official captures include a 0x0145 TLV before 0x0143, and successful multi-page
// manual SpO2 syncs increment it across successive delete requests in the same session.
// The cursor in 0x0143 is treated as an opaque delete token for the current head page,
// not as a "next page" pointer.
deleteMessage.addDataStructure(new FeatureTagsUserId(deleteRequestId));
deleteMessage.addDataStructure(new StoredSignalMeta(signalType, signalFlags, cursor));
addSimpleConversationFirst(deleteMessage, handler);
@@ -833,6 +839,8 @@ public abstract class WithingsBaseDeviceSupport extends AbstractBTLESingleDevice
final int cursor,
final ResponseHandler handler) {
Message getMessage = new WithingsMessage(WithingsMessageType.GET_STORED_MEASURE_SIGNAL, ExpectedResponse.EOT);
// For stored-measure queues the official app repeatedly re-reads the queue head with
// cursor 0. The returned StoredSignalMeta carries the delete key for the current head item.
getMessage.addDataStructure(new StoredSignalMeta(signalType, signalFlags, cursor));
addSimpleConversationFirst(getMessage, handler);
}
@@ -62,6 +62,16 @@ public class StoredMeasureSignalHandler implements ResponseHandler {
private int lastDeletedSpo2 = -1;
private int repeatedPageRetries;
/**
* Handles the head-based stored-measure queues used for SpO2 and mixed stored signals.
*
* Based on official app HCI captures, these queues do not behave like classic pagination.
* Gadgetbridge re-requests cursor 0, parses the returned head page, stores any SpO2 / HR
* samples, and then deletes that exact page using the returned 0x0143 key. Some head pages
* are mixed and contain both ECG metadata and SpO2 values, so this handler may hand off ECG
* retrieval to {@link WithingsEcgHandler} before resuming deletion of the same queue head.
*/
public StoredMeasureSignalHandler(final WithingsBaseDeviceSupport support, final GBDevice device, final int signalType) {
this.support = support;
this.device = device;
@@ -221,6 +231,9 @@ public class StoredMeasureSignalHandler implements ResponseHandler {
logger.info("Stored-measure page signalType={} cursor={} contains an already-seen ECG record; continuing with page deletion instead of re-fetching it again",
signalType, currentCursor);
} else {
// Mixed signalType=0x0004 pages can contain an ECG record key plus SpO2 data.
// Match the official app by fetching the ECG waveform first, then returning to
// the same stored-measure queue head so the mixed page can still be deleted.
logger.info("Stored-measure page signalType={} cursor={} contains ECG markers; scheduling ECG fetch before continuing this stored-measure loop",
signalType, currentCursor);
support.notifyEcgRecordDiscovered(ecgMetaToNotify, signalType);
@@ -71,6 +71,16 @@ public class WithingsEcgHandler implements ResponseHandler {
private EcgWaveformHandler activeWaveformHandler;
private int repeatedSpo2FallbackRetries;
/**
* Mirrors the official ECG sync split seen in HCI captures:
* first discover stored ECG record keys via MEASURE_* responses, then fetch each waveform by
* replaying the exact 0x0116 key returned by discovery.
*
* This discovery stage means ECG progress is theoretically knowable because the watch tells
* us how many ECG record keys exist up front. The current implementation uses discovery for
* fetching order and dedupe, but it does not yet surface a user-visible 0-100 progress value.
*/
public WithingsEcgHandler(final WithingsBaseDeviceSupport support, final GBDevice device) {
this.support = support;
this.device = device;
@@ -199,6 +209,8 @@ public class WithingsEcgHandler implements ResponseHandler {
if (timestampMs > 0 && !seenRecordTimestamps.contains(timestampMs)) {
seenRecordTimestamps.add(timestampMs);
}
// Discovery gives us the same record key the official app later replays in
// GET_STORED_MEASURE_SIGNAL to fetch the full waveform.
logger.info("Discovered Withings ECG record ts={} type={}", recordKey.getTimestampMs(), recordKey.getMeasurementType());
waveformFetchQueued = true;
activeWaveformHandler = new EcgWaveformHandler(recordKey, true, false, false, -1);
@@ -379,6 +391,8 @@ public class WithingsEcgHandler implements ResponseHandler {
logger.warn("Stopping repeated ECG fallback for ts={} after {} missing-delete-key retries; not advancing cursor because official app behavior appears head-page based",
startTimestampMs, repeatedSpo2FallbackRetries - 1);
} else {
// Retry from cursor 0 because mixed stored-signal pages behave as a queue head,
// not a pageable list. Advancing the cursor risks skipping a stubborn head page.
support.queueGetStoredMeasureSignal(originatingSignalType, 0, new StoredMeasureSignalHandler(support, device, originatingSignalType));
}
} else if (discoveryPagesSeen < MAX_DISCOVERY_PAGES) {