From 8145f0d887f9cbc73f0c3d6dab114323cb561fa3 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 28 Aug 2026 22:29:42 +1200 Subject: [PATCH 1/3] alarm: let a 4.0 be asked what alarm it has stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to #1706, which now persists the raw readback frame but had no way to obtain one. On Android the GET_ALARM_TIME readback was reachable only by ARMING, so an install with the alarm switched off could not produce the evidence that would explain what its strap reports — which is exactly the state of the account whose 2045 readback prompted the issue. iOS has getStrapAlarm for this, but it has never had a caller on either platform, so porting the function alone would only have added dead code to a second platform. So: the port, plus a way to press it. A "Check what the strap has stored" button on the strap firmware-alarm card, shown when a strap is connected and the 5/MG branch has not claimed the card. The reply lands on the same GET_ALARM_TIME branch an arm's follow-up read uses, so it persists the reported epoch and the raw bytes for the debug export. Deliberately not family-gated, unlike armStrapAlarm. Only the 4.0 branch DECODES the reply, but a 5/MG still logs whatever frame comes back with its raw hex, and on a strap whose readback layout is undocumented that raw frame is the whole point of the button. Refusing to send would throw away the one thing worth capturing. Both strings are hand-authored, so they are named without the l10n_ prefix that in this file marks a key the extractor generated with a content hash. Verified: 4,725 Android tests, 0 failures; i18n audit clean; lintVitalFullRelease clean, run on its own because combining it with the debug test tasks trips a task-wiring validation bug that reproduces on unmodified main. --- .../main/java/com/noop/ble/WhoopBleClient.kt | 20 +++++++++++++++++++ .../main/java/com/noop/ui/SmartAlarmScreen.kt | 16 +++++++++++++++ .../app/src/main/res/values-de/strings.xml | 2 ++ .../app/src/main/res/values-es/strings.xml | 2 ++ .../app/src/main/res/values-fr/strings.xml | 2 ++ .../app/src/main/res/values-pl/strings.xml | 2 ++ .../src/main/res/values-pt-rPT/strings.xml | 2 ++ .../app/src/main/res/values-zh/strings.xml | 2 ++ android/app/src/main/res/values/strings.xml | 2 ++ 9 files changed, 50 insertions(+) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 51c212cc0d..a92fcec99d 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -4526,6 +4526,26 @@ class WhoopBleClient( } } + /** + * Ask the strap what alarm it currently has stored (#1706). The reply lands on the command-notify + * characteristic and is handled by the same GET_ALARM_TIME branch an arm's own follow-up read uses, + * so it persists `alarm.lastReportedEpoch` + the RAW response bytes for the debug export. + * + * Port of Swift `BLEManager.getStrapAlarm`, which until now had no caller on either platform. It + * exists here because the readback was otherwise only reachable by ARMING: a user whose alarm is off + * could not produce the evidence needed to explain what their strap reports, and on Android arming + * was the sole trigger. + * + * Deliberately NOT family-gated, unlike [armStrapAlarm]. Only the 4.0 branch DECODES the reply into + * `alarm.lastReportedEpoch`, but a 5/MG still logs whatever frame comes back with its raw hex — and + * on a strap whose readback layout is undocumented, that raw frame is the whole point of the button. + * Refusing to send would throw away the one thing worth capturing. + */ + fun getStrapAlarm() { + send(CommandNumber.GET_ALARM_TIME, byteArrayOf(0x01)) + log("Alarm: requested current alarm time") + } + /** * Arm the strap's **firmware** alarm to buzz at [epochSec] (absolute UTC seconds). The strap fires * at that instant even if the phone is asleep or NOOP is closed. SET_CLOCK is sent first so the diff --git a/android/app/src/main/java/com/noop/ui/SmartAlarmScreen.kt b/android/app/src/main/java/com/noop/ui/SmartAlarmScreen.kt index b642549f34..660ab8bc37 100644 --- a/android/app/src/main/java/com/noop/ui/SmartAlarmScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SmartAlarmScreen.kt @@ -287,6 +287,22 @@ private fun StrapAlarmCard(vm: AppViewModel) { "Connect your strap to arm this; it's set on the strap's own firmware alarm. Confirmed working on WHOOP 4.0; still experimental on 5.0 and MG. Keep a backup alarm for anything you truly can't miss.", style = NoopType.footnote, color = Palette.textTertiary, ) + // #1706: ask the strap what it actually has stored. The readback was previously only + // reachable by ARMING, so anyone whose alarm is off could not produce the evidence + // that explains a wrong reported time. 4.0 only — the 5/MG readback is not decoded. + if (live.bonded) { + RowDividerLocal() + NoopButton( + text = uiString(R.string.smart_alarm_check_strap_alarm), + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = { vm.ble.getStrapAlarm() }, + ) + Text( + uiString(R.string.smart_alarm_check_strap_alarm_help), + style = NoopType.footnote, color = Palette.textTertiary, + ) + } } } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index e1857952ce..67f8a56d1f 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -860,6 +860,8 @@ Erinnere mich ans Runterkommen Fenster verkürzen Strap-Weckalarm + Prüfen, was das Band gespeichert hat + Die Antwort des Bands erscheint im Band-Log und im Diagnose-Export, samt der empfangenen Rohbytes. Der früheste NOOP wird Sie wecken. Der garantierte Alarm feuert so lange nach Ihrer frühesten Zeit. Dies ist ein grober Hinweis auf die Herzfrequenz, keine klinische Schlafphase. Wenn das Gurtband diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index e3a0732790..706c3aea11 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -659,6 +659,8 @@ Recuérdame relajarme Ventana corta Alarma de despertar de la pulsera + Comprobar qué tiene guardado la pulsera + La respuesta de la pulsera aparece en el registro y en la exportación de diagnóstico, con los bytes recibidos. La primera NOOP te despertará. La alarma garantizada dispara tanto tiempo después de su primer tiempo. Este es un cue grueso de la frecuencia cardíaca, no una lectura clínica del estadio del sueño. Si la correa diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index a70dea87fc..8c81a0f00b 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -677,6 +677,8 @@ Me rappeler de me détendre Écourter la fenêtre Alarme de réveil du bracelet + Vérifier ce que le bracelet a enregistré + La réponse du bracelet apparaît dans le journal et l’export de diagnostic, avec les octets bruts reçus. Le premier NOOP vous réveillera. L\'alarme garantie tire aussi longtemps après votre plus jeune heure. Il s\'agit d\'un signal grossier de la fréquence cardiaque, et non d\'une lecture clinique au stade du sommeil. Si le bracelet diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 93827ec201..651edae8b2 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -811,6 +811,8 @@ Przypomnij mi, żebym się uspokoił Skróć okno Budzik na pasek + Sprawdź, co zapisała opaska + Odpowiedź opaski pojawi się w logu i eksporcie diagnostycznym, wraz z otrzymanymi surowymi bajtami. Obudzi Cię najwcześniej NOOP. Gwarantowany alarm uruchomi się tak długo po najwcześniejszym momencie. Jest to przybliżona wskazówka na podstawie tętna, a nie kliniczny odczyt na etapie snu. Jeśli pasek diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 7d7936250e..d14c3abe8a 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -825,6 +825,8 @@ Lembre-me de relaxar Encurtar janela Bracelete despertador + Verificar o que a pulseira tem guardado + A resposta da pulseira aparece no registo e na exportação de diagnóstico, com os bytes recebidos. O primeiro NOOP irá acordá-lo. O alarme garantido dispara muito depois do primeiro horário. Esta é uma indicação grosseira da frequência cardíaca, não uma leitura clínica da fase do sono. Se a pega diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 54847fb5aa..dd1df9fad5 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -807,6 +807,8 @@ 提醒我准备入睡 缩短唤醒窗口 手环无声唤醒 + 查看手环已存储的闹钟 + 手环的回应会出现在手环日志和诊断导出中,并附带收到的原始字节。 NOOP 可能会唤醒您的最早时间。 如果您在这段时间内未被智能唤醒,保底闹钟将在窗口期结束时必定响起。 这是根据心率变动做出的粗略提示,而非临床级别的睡眠阶段判断。如果手环... diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cdfd989943..f699e2b81d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -945,6 +945,8 @@ Remind me to wind down Shorten window Strap wake-alarm + Check what the strap has stored + The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with. The earliest NOOP will wake you. The guaranteed alarm fires this long after your earliest time. This is a coarse cue from heart rate, not a clinical sleep-stage reading. If the strap From bc4dfa410dfd38feab6ebea9553dbb1136981795 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 28 Aug 2026 22:50:04 +1200 Subject: [PATCH 2/3] alarm: stop the readback doc claiming a path its only caller cannot take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review. The doc defended leaving getStrapAlarm ungated by saying a 5/MG still logs whatever frame comes back and that the raw frame is the whole point of the button. But the button renders only in the non-5/MG branch of the strap-alarm card, so the method can never be invoked on a 5/MG: the rationale described an unreachable path. Now says what is true. Ungated is defensive rather than a feature — send already no-ops when nothing is connected and only the 4.0 branch decodes the reply, so an errant call costs one ignored write — and it states outright that nothing captures the 5/MG frame today, with a note on where a caller would go if that layout ever needs recording. Verified: 4,725 Android tests, 0 failures. --- android/app/src/main/java/com/noop/ble/WhoopBleClient.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index a92fcec99d..759e30f5fc 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -4536,10 +4536,11 @@ class WhoopBleClient( * could not produce the evidence needed to explain what their strap reports, and on Android arming * was the sole trigger. * - * Deliberately NOT family-gated, unlike [armStrapAlarm]. Only the 4.0 branch DECODES the reply into - * `alarm.lastReportedEpoch`, but a 5/MG still logs whatever frame comes back with its raw hex — and - * on a strap whose readback layout is undocumented, that raw frame is the whole point of the button. - * Refusing to send would throw away the one thing worth capturing. + * Not family-gated, unlike [armStrapAlarm] — but that is defensive, not a feature. [send] already + * no-ops when nothing is connected, and only the 4.0 branch decodes the reply, so an errant call + * costs one ignored write. The single caller today is the 4.0 side of the strap-alarm card, so the + * 5/MG frame is NOT currently captured by anything; a caller could be added there if that layout + * ever needs recording, and this method would not have to change. */ fun getStrapAlarm() { send(CommandNumber.GET_ALARM_TIME, byteArrayOf(0x01)) From 62e23c823fb763e7333d9e32ec343a7f4d6566b4 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 28 Aug 2026 23:03:22 +1200 Subject: [PATCH 3/3] alarm: give iOS the same strap-readback button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity. Before this branch neither platform had a caller for getStrapAlarm, which was symmetric even if useless. Adding the Android button alone made it a feature-level gap, and on the wrong side: the alarm.rejectStreak warning is iOS-only, so iOS users are the ones more likely to meet a confusing readback with no way to capture the frame behind it. Same card, same copy, same command. One deliberate difference, commented at the call site: the Android button hides until bonded, this one is always shown. Gating is free on Android because that card already observes LiveState; SmartAlarmView does not, and this codebase keeps `live` out of parent views so a 1 Hz HR tick cannot re-render them. getStrapAlarm no-ops and logs when nothing is connected, so the cost of showing it is one ignored write rather than a per-second re-render of the whole alarm screen. Both strings carry all ten iOS locales; pl is ratcheted, so a missing one hard-fails the audit. The catalogue was edited as TEXT, not re-serialized. Re-serializing with json.dumps(indent=2) expands the file's hand-compacted entries and turns a two-string addition into a 3,426-line diff — the first attempt here did exactly that. The kept version is six added lines in the neighbours' own style, re-parsed to prove it still loads. Verified: i18n audit clean; 4,725 Android tests still green. SmartAlarmView is app-target, so the Swift half needs app-build. --- Strand/Resources/Localizable.xcstrings | 6 ++++++ Strand/Screens/SmartAlarmView.swift | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index d78bd6608c..1ad4bf61c4 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -208218,6 +208218,12 @@ } }, "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more.": { "localizations": { "de": { "stringUnit": { "state": "translated", "value": "Eine kardiovaskuläre Last aus der Zeit in den Herzfrequenzzonen (Edwards-TRIMP): Jede Zone wird gewichtet, sodass härtere Zonen mehr zählen, aufsummiert zu einer Tageszahl. In den Einstellungen gibt es eine exponentielle Alternative (Banister), die kurze, harte Belastungen stärker anrechnet." } }, "en": { "stringUnit": { "state": "translated", "value": "A cardiovascular load from time in heart-rate zones (Edwards TRIMP): each zone is weighted so harder ones count for more, summed into one daily figure. Settings offers an exponential alternative (Banister) that credits short, hard efforts more." } }, "es": { "stringUnit": { "state": "translated", "value": "Una carga cardiovascular a partir del tiempo en las zonas de frecuencia cardíaca (TRIMP de Edwards): cada zona se pondera para que las más duras cuenten más, sumado en una sola cifra diaria. En Ajustes hay una alternativa exponencial (Banister) que valora más los esfuerzos cortos e intensos." } }, "fr": { "stringUnit": { "state": "translated", "value": "Une charge cardiovasculaire issue du temps passé dans les zones de fréquence cardiaque (TRIMP d’Edwards) : chaque zone est pondérée pour que les plus intenses comptent davantage, additionnée en un seul chiffre journalier. Les Réglages proposent une alternative exponentielle (Banister) qui valorise davantage les efforts courts et intenses." } }, "it": { "stringUnit": { "state": "translated", "value": "Un carico cardiovascolare dato dal tempo nelle zone di frequenza cardiaca (TRIMP di Edwards): ogni zona è ponderata in modo che quelle più dure contino di più, sommato in un unico dato giornaliero. Nelle Impostazioni c’è un’alternativa esponenziale (Banister) che premia di più gli sforzi brevi e intensi." } }, "pl": { "stringUnit": { "state": "translated", "value": "Obciążenie układu krążenia wynikające z czasu w strefach tętna (TRIMP Edwardsa): każda strefa jest ważona tak, aby trudniejsze liczyły się bardziej, zsumowane w jedną wartość dzienną. W Ustawieniach jest alternatywa wykładnicza (Banister), która mocniej docenia krótkie, intensywne wysiłki." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Uma carga cardiovascular a partir do tempo nas zonas de frequência cardíaca (TRIMP de Edwards): cada zona é ponderada para que as mais difíceis contem mais, resumido num valor diário. Nas Definições existe uma alternativa exponencial (Banister) que valoriza mais os esforços curtos e intensos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Сердечно-сосудистая нагрузка по времени в пульсовых зонах (TRIMP Эдвардса): каждая зона взвешивается так, что более тяжёлые учитываются сильнее, и всё суммируется в одно дневное значение. В настройках есть экспоненциальная альтернатива (Banister), которая сильнее засчитывает короткие интенсивные усилия." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "根据你在各心率区间的时长得出的心血管负荷(Edwards TRIMP):区间越高权重越大,汇总为一个每日数字。设置中提供指数式替代方案(Banister),会更看重短时高强度的努力。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "根據你在各心率區間停留時間得出的心血管負荷(Edwards TRIMP):區間越高權重越大,加總成每日一個數字。設定中提供指數式替代方案(Banister),會更看重短時高強度的努力。" } } + } }, + "Check what the strap has stored": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Prüfen, was das Band gespeichert hat" } }, "en": { "stringUnit": { "state": "translated", "value": "Check what the strap has stored" } }, "es": { "stringUnit": { "state": "translated", "value": "Comprobar qué tiene guardado la correa" } }, "fr": { "stringUnit": { "state": "translated", "value": "Vérifier ce que le bracelet a enregistré" } }, "it": { "stringUnit": { "state": "translated", "value": "Controlla cosa ha memorizzato il braccialetto" } }, "pl": { "stringUnit": { "state": "translated", "value": "Sprawdź, co zapisała opaska" } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "Verificar o que a pulseira tem guardado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Проверить, что сохранено на ремешке" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "查看手环已存储的闹钟" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "查看手環已儲存的鬧鐘" } } + } }, + "The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with.": { "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Antwort des Bands erscheint im Band-Log und im Diagnose-Export, samt der empfangenen Rohbytes." } }, "en": { "stringUnit": { "state": "translated", "value": "The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with." } }, "es": { "stringUnit": { "state": "translated", "value": "La respuesta de la correa aparece en el registro y en la exportación de diagnóstico, con los bytes recibidos." } }, "fr": { "stringUnit": { "state": "translated", "value": "La réponse du bracelet apparaît dans le journal et l’export de diagnostic, avec les octets bruts reçus." } }, "it": { "stringUnit": { "state": "translated", "value": "La risposta del braccialetto compare nel log e nell’esportazione diagnostica, con i byte grezzi ricevuti." } }, "pl": { "stringUnit": { "state": "translated", "value": "Odpowiedź opaski pojawi się w logu i eksporcie diagnostycznym, wraz z otrzymanymi surowymi bajtami." } }, "pt-PT": { "stringUnit": { "state": "translated", "value": "A resposta da pulseira aparece no registo e na exportação de diagnóstico, com os bytes recebidos." } }, "ru": { "stringUnit": { "state": "translated", "value": "Ответ ремешка появится в журнале ремешка и диагностическом экспорте вместе с полученными байтами." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "手环的回应会出现在手环日志和诊断导出中,并附带收到的原始字节。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "手環的回應會出現在手環日誌和診斷匯出中,並附上收到的原始位元組。" } } } } }, "version": "1.0" diff --git a/Strand/Screens/SmartAlarmView.swift b/Strand/Screens/SmartAlarmView.swift index f69c181abf..0f6112abc9 100644 --- a/Strand/Screens/SmartAlarmView.swift +++ b/Strand/Screens/SmartAlarmView.swift @@ -242,6 +242,27 @@ struct SmartAlarmView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) + // #1706: ask the strap what it actually has stored. The readback was otherwise + // only reachable by ARMING, so anyone whose alarm is off could not produce the + // frame that explains a wrong reported time. + // + // Shown unconditionally, where the Android twin hides it until bonded. That is a + // deliberate platform difference, not drift: the Android card already observes + // LiveState, so gating there is free, while this view does not — and pulling + // `live` in would re-render the whole alarm screen on every 1 Hz HR tick, which + // this codebase keeps parent views clear of on purpose. `getStrapAlarm` no-ops + // and logs when nothing is connected, so the worst case is one ignored write. + Divider().overlay(StrandPalette.hairline) + Button { + model.ble.getStrapAlarm() + } label: { + Text("Check what the strap has stored") + } + .buttonStyle(NoopButtonStyle(.secondary, fullWidth: true)) + Text("The answer from the strap appears in your strap log and debug export, with the raw bytes it replied with.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) } } }