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) } } } 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..759e30f5fc 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,27 @@ 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. + * + * 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)) + 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