Skip to content

Add diagnostic WHOOP GATT family detection - #713

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
tigercraft4:diag/whoop-extra-gatt-families
Jul 22, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
tigercraft4:diag/whoop-extra-gatt-families

Conversation

@tigercraft4

Copy link
Copy Markdown

Summary

Closes #688.

Adds diagnostic-only metadata and scan detection for the additional WHOOP GATT service families seen in the decompiled app:

  • puffin1150: 11500001-6215-11ee-8c99-0242ac120002
  • monument: 8a580001-2fe8-4796-9267-b87a2b0c8234
  • symphony: 59830001-5955-419b-bb8d-c8262926af23

The new families are deliberately not added as connectable DeviceFamily cases. WHOOP4 and fd4b Maverick/Goose remain the only connectable families.

Behavior

  • Broadens normal WHOOP scans to include the diagnostic-only service UUIDs.
  • If an unsupported family advertises, the scan callback logs a clear detected-but-unsupported message and returns before model persistence or connect.
  • Existing WHOOP 4 and fd4b WHOOP 5/MG connect behavior remains unchanged.
  • No Castle/Rev2 commands, characteristic discovery, CLIENT_HELLO, or framing path is used for puffin1150, monument, or symphony.

Naming

Keeps NOOP's existing fd4b "puffin" code names untouched. The separate official-app PUFFIN service family is named puffin1150 in docs/Swift and PUFFIN_1150 in Kotlin to avoid colliding with fd4b packet framing terminology.

Tests

  • swift test --package-path Packages/WhoopProtocol --filter DeviceFamilyFramingTests
  • JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/21.0.11/libexec/openjdk.jdk/Contents/Home ANDROID_HOME=$HOME/Library/Android/sdk ./gradlew :app:testFullDebugUnitTest --tests 'com.noop.protocol.WhoopGattServiceFamilyTest'

Additional note: xcodebuild -project Strand.xcodeproj -scheme Strand -configuration Debug -destination 'platform=macOS' build currently fails on an unrelated existing OuraActivityDump missing symbol in Strand/BLE/OuraLiveSource.swift, not in the files touched here.

@ryanbr
ryanbr merged commit a2b3f7b into ryanbr:main Jul 22, 2026
9 checks passed
mwong56 pushed a commit to mwong56/noop that referenced this pull request Jul 22, 2026
… platforms (ryanbr#250)

* Predictive low-battery alert: warn at ~24h of estimated runtime left

The fixed 15% low alert (ryanbr#368) gives ~16h of warning on a WHOOP 4.0 but
~1.8 days on a 5.0/MG. BatteryEstimator (ryanbr#713) already fits a personal
runtime estimate from the banked SoC series; connect the two:

- BatteryEstimator.runtimeAlert (Swift + Kotlin twins, same fixtures):
  fire when remainingHours <= 24 and not confirmed charging, re-arm only
  at >= 36 so slope jitter can't re-fire; suppressed-while-charging does
  not consume the once-per-discharge gate.
- iOS/macOS: BatteryNotifier.onRuntimeEstimate posts "~22h left on your
  WHOOP" with the live figure; evaluated in AppModel's existing battery
  hook where the just-banked estimate is fresh.
- Android: BatteryAlertNotifier.onRuntimeEstimate (id 4205) fed from
  WhoopConnectionService, re-fitting from the persisted battery table
  only when the SoC actually changes (~8-min strap cadence); same
  samples/rated inputs as the Today badge.
- The 15% alert stays as the safety net (covers the nil-estimate case);
  everything remains behind the existing "Battery alerts" setting.

Verified: StrandAnalytics swift test (980 passing, 7 new), Strand and
NOOPiOS schemes compile. Android JVM tests are behavior-identical
mirrors of the Swift fixtures; no Android toolchain on this machine, so
Kotlin is signature-checked against the existing call-site patterns but
not compiled here.

* Granular sub-toggle for the predictive runtime alert (default ON)

Review ask on ryanbr#250: the predictive ~24h "recharge tonight" warning can now be
silenced on its own without losing the 15% low / 100% full alerts.

- iOS/macOS: behavior.batteryPredictiveAlerts in BehaviorStore (default true),
  ANDed with behavior.batteryAlerts in the enabled value AppModel passes to
  BatteryNotifier.onRuntimeEstimate; "Predictive runtime warning" sub-row under
  Battery alerts in Automations (shown while the parent toggle is on).
- Android twin: noop.batteryPredictiveAlerts in NoopPrefs (default true, suffix
  parity with the iOS key), checked at the top of
  BatteryAlertNotifier.onRuntimeEstimate alongside the existing batteryAlerts
  gate; matching sub-row in AutomationsScreen via AppViewModel.

Kotlin written blind per the parity note (no Android toolchain here); Swift
side verified with a clean NOOPiOS simulator build.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
… platforms (ryanbr#250)

* Predictive low-battery alert: warn at ~24h of estimated runtime left

The fixed 15% low alert (ryanbr#368) gives ~16h of warning on a WHOOP 4.0 but
~1.8 days on a 5.0/MG. BatteryEstimator (ryanbr#713) already fits a personal
runtime estimate from the banked SoC series; connect the two:

- BatteryEstimator.runtimeAlert (Swift + Kotlin twins, same fixtures):
  fire when remainingHours <= 24 and not confirmed charging, re-arm only
  at >= 36 so slope jitter can't re-fire; suppressed-while-charging does
  not consume the once-per-discharge gate.
- iOS/macOS: BatteryNotifier.onRuntimeEstimate posts "~22h left on your
  WHOOP" with the live figure; evaluated in AppModel's existing battery
  hook where the just-banked estimate is fresh.
- Android: BatteryAlertNotifier.onRuntimeEstimate (id 4205) fed from
  WhoopConnectionService, re-fitting from the persisted battery table
  only when the SoC actually changes (~8-min strap cadence); same
  samples/rated inputs as the Today badge.
- The 15% alert stays as the safety net (covers the nil-estimate case);
  everything remains behind the existing "Battery alerts" setting.

Verified: StrandAnalytics swift test (980 passing, 7 new), Strand and
NOOPiOS schemes compile. Android JVM tests are behavior-identical
mirrors of the Swift fixtures; no Android toolchain on this machine, so
Kotlin is signature-checked against the existing call-site patterns but
not compiled here.

* Granular sub-toggle for the predictive runtime alert (default ON)

Review ask on ryanbr#250: the predictive ~24h "recharge tonight" warning can now be
silenced on its own without losing the 15% low / 100% full alerts.

- iOS/macOS: behavior.batteryPredictiveAlerts in BehaviorStore (default true),
  ANDed with behavior.batteryAlerts in the enabled value AppModel passes to
  BatteryNotifier.onRuntimeEstimate; "Predictive runtime warning" sub-row under
  Battery alerts in Automations (shown while the parent toggle is on).
- Android twin: noop.batteryPredictiveAlerts in NoopPrefs (default true, suffix
  parity with the iOS key), checked at the top of
  BatteryAlertNotifier.onRuntimeEstimate alongside the existing batteryAlerts
  gate; matching sub-row in AutomationsScreen via AppViewModel.

Kotlin written blind per the parity note (no Android toolchain here); Swift
side verified with a clean NOOPiOS simulator build.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…families

Add diagnostic WHOOP GATT family detection
ryanbr pushed a commit that referenced this pull request Jul 27, 2026
…ght-guard (#864)

Both existing battery alerts fire at most once per discharge cycle and then latch — the 15% SoC
alert holds until the cell recovers to 25%, the 24 h predictive alert (#713) until the estimate
returns to 36 h. Neither re-arms on anything but a charge, so a strap that just keeps draining goes
silent exactly as the news gets worse. Measured on a 5.0 running R22 deep-data + high-rate IMU:
both had already fired, then nothing across the final descent, and a full night was lost. At the
measured 1.65 %/h that silence ran ~3 h with the app still showing ~6 h of runway.

Two additions, each with its OWN persisted gate so a latched low/runtime alert can never suppress
them.

Critical SoC at 12%, derived from the hardware cutoff rather than picked round. The strap goes dark
near 10% — the last HR sample on the reference run landed 27 min after the 10.9% reading and nothing
below it was ever banked — so the usable band is (10%, 15%]. A conventional 5% is unreachable on this
hardware and 10% is a coin flip. 12% sits mid-band, was reliably reported, and clears the 15% line by
3 pp so it is a genuinely separate crossing. Fired once on the real curve, 90 min before death.

Bedtime night-guard near the LEARNED habitual bedtime (#547 model reused, not reinvented), firing
when the strap will not clear wait-until-bed + the night + a margin. Re-arms on leaving the window,
so once per NIGHT rather than once per charge — it speaks when everything else has latched. Cold
start returns silent; no fabricated bedtime.

Underneath both, usableRemainingHours re-anchors the estimate to the ~10% cutoff:
remainingHours * (soc - cutoff) / soc, rearranged so the rate is never recovered. Verified
numerically against computing it directly. The #99 clamp carries through conservatively — it only
ever lowers remainingHours, so the usable figure can only be pessimistic.

Added ADDITIVELY on purpose: remainingHours still feeds the Today badge and the Kotlin shared
fixtures, so re-anchoring it in place would have moved a displayed number and broken documented
parity in the same commit. Verified untouched.

Parity checked mechanically, all six constants identical across platforms: cutoffSocPct 10.0,
criticalSocPct 12, criticalRearmAbovePct 25, bedtimeLeadHours 3.0, bedtimeMarginHours 1.0,
bedtimeMinNights 7. Android has no per-notification .timeSensitive; the shared channel is already
IMPORTANCE_HIGH so both escalations heads-up, and setBypassDnd() is deliberately NOT taken since it
needs user-granted policy access. Documented in the Kotlin rather than left as a silent divergence.

The post() change is one defaulted parameter, so every existing call site is unchanged.

Policy is pure and lives in StrandAnalytics, so swift test reaches it with no app, no strap, no
CoreBluetooth. 10/10 CI green.

KNOWN GAP, follow-up immediately: the four new Apple notification strings are not in
Localizable.xcstrings, so they ship English on iOS while the Android twin is translated into all six
locale files. i18n_audit cannot see this — it checks that entries already IN the catalog have every
language, and a String(localized:) literal that never got extracted is invisible to it. That is #844
exactly. Two further literals in the same file were already missing before this PR.
ryanbr added a commit that referenced this pull request Jul 27, 2026
…e catalog (#866)

Follow-up to #864. Resource-only, no code change.

#864 added four String(localized:) literals in Strand/System/BatteryNotifier.swift and translated
its Android twin into all five shipped locale files, but the Apple catalog was never updated — so
both escalations shipped English on iOS/macOS in every language. Two further literals in the same
file (the #713 runtime alert) were already missing before that PR.

Six entries added, all eight shipped Apple languages:

  Strap battery low
  %@ left on your WHOOP — recharge tonight.
  Charge your WHOOP now
  %lld%% left. The strap stops recording near 10%% — it won't capture tonight unless you charge it.
  Won't last the night
  %@ of recording left, but tonight needs about %@. Charge before bed.

WHY CI WAS GREEN ON #864. The audit checks that keys already IN a catalog carry every language. A
String(localized:) literal that was never extracted is not a missing translation, it is a missing
key, and is invisible to it. That is #844 exactly; this is the manual patch, not the fix.

KEYS. Derived mechanically from the source literals rather than retyped: String interpolation to
%@, Int to %lld, literal % to %% inside an interpolated string (a non-interpolated one keeps a bare
%, which is why the existing "Your WHOOP is at 100%." is stored unescaped). That mapping is not
assumed — it is the observed encoding of XiaomiBandView.swift:268, whose literal produced the
existing key "%@ in bed · %lld%% efficiency", exercising all three conversions in one string. Keys
stay non-positional and translations use %1$/%2$ where word order moves, matching how the catalog
already stores its two-argument entries.

REGISTER. de, fr and zh-Hans reuse the Android wording. es and pt-PT do not: #864 translated those
formally, while this catalog is informal by a wide margin (es tu/tus/tú 762 against su/sus/usted
66; pt-PT teu/tua 689 against seu/sua/você 20) and the battery strings it already ships next to
these are informal. Each language also uses the word for "strap" the catalog already established
for it (Strap, pulsera, bracelet, fascia, correia, браслет, 手环, 手環) rather than importing
Android's. Register and vocabulary only; no numbers move and no Android file is touched.

GRAMMATICAL AGREEMENT. The substituted %@ is BatteryEstimator.label output — "~14h" or "~1h" — so a
body opening with a verb must commit to singular or plural against a value it cannot see, and
because the argument is an opaque string, xcstrings plural variations cannot key on it either. The
three languages that inflect here are recast as label-then-value ("Autonomía restante en tu WHOOP:
%@"). de, fr, ru and both Chinese variants were already agreement-free. Same treatment in the
critical body, where %lld can be anything from 1 to criticalSocPct = 12, so both "Queda 1%" and
"Quedan 12%" occur: it states the level instead.

Two further wording fixes from a final read-through: the fr/it/pt-PT low-battery title placed the
adjective after the strap noun, where it attaches to the strap and — since faible and scarica are
also verb forms — can be read as a sentence about the strap discharging; and the es bedtime body
ended in a feminine clitic with no antecedent, where every other Spanish string in this feature
treats the device as masculine. Both now match how each language already renders the existing "Low
battery" key.

it, ru and zh-Hant are new translations and have NOT been checked by a native speaker.

VERIFICATION. Resource-only, so nothing to compile, and both shipping Apple targets pick it up:
Strand and NOOPiOS each take "- path: Strand" wholesale, excluding only Info.plist, entitlements
and Assets.xcassets, so the catalog and BatteryNotifier.swift land in both. All 10 String(localized:)
literals in that file now resolve to catalog keys. Argument types, positions and literal-percent
counts were checked against every key across 6 keys x 8 languages after each rewrite, so no format
string can garble at substitution. Measured against origin/main: 6 keys added, 0 removed, 0
pre-existing entries modified, and the catalog moves 3259 to 3265 keys with every per-language
missing count unchanged. i18n_audit.py --ci origin/main green; Tools/i18n_extra_locale_baseline.txt
untouched and unaffected.

NOT IN SCOPE. Android hardcodes six battery strings as raw Kotlin literals rather than R.string
(BatteryAlertNotifier.kt:123,124,151,152,163,164), so the runtime, low and full alerts ship English
there in every locale — the same bug in the opposite direction. Filed as #867.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… platforms (ryanbr#250)

* Predictive low-battery alert: warn at ~24h of estimated runtime left

The fixed 15% low alert (ryanbr#368) gives ~16h of warning on a WHOOP 4.0 but
~1.8 days on a 5.0/MG. BatteryEstimator (ryanbr#713) already fits a personal
runtime estimate from the banked SoC series; connect the two:

- BatteryEstimator.runtimeAlert (Swift + Kotlin twins, same fixtures):
  fire when remainingHours <= 24 and not confirmed charging, re-arm only
  at >= 36 so slope jitter can't re-fire; suppressed-while-charging does
  not consume the once-per-discharge gate.
- iOS/macOS: BatteryNotifier.onRuntimeEstimate posts "~22h left on your
  WHOOP" with the live figure; evaluated in AppModel's existing battery
  hook where the just-banked estimate is fresh.
- Android: BatteryAlertNotifier.onRuntimeEstimate (id 4205) fed from
  WhoopConnectionService, re-fitting from the persisted battery table
  only when the SoC actually changes (~8-min strap cadence); same
  samples/rated inputs as the Today badge.
- The 15% alert stays as the safety net (covers the nil-estimate case);
  everything remains behind the existing "Battery alerts" setting.

Verified: StrandAnalytics swift test (980 passing, 7 new), Strand and
NOOPiOS schemes compile. Android JVM tests are behavior-identical
mirrors of the Swift fixtures; no Android toolchain on this machine, so
Kotlin is signature-checked against the existing call-site patterns but
not compiled here.

* Granular sub-toggle for the predictive runtime alert (default ON)

Review ask on ryanbr#250: the predictive ~24h "recharge tonight" warning can now be
silenced on its own without losing the 15% low / 100% full alerts.

- iOS/macOS: behavior.batteryPredictiveAlerts in BehaviorStore (default true),
  ANDed with behavior.batteryAlerts in the enabled value AppModel passes to
  BatteryNotifier.onRuntimeEstimate; "Predictive runtime warning" sub-row under
  Battery alerts in Automations (shown while the parent toggle is on).
- Android twin: noop.batteryPredictiveAlerts in NoopPrefs (default true, suffix
  parity with the iOS key), checked at the top of
  BatteryAlertNotifier.onRuntimeEstimate alongside the existing batteryAlerts
  gate; matching sub-row in AutomationsScreen via AppViewModel.

Kotlin written blind per the parity note (no Android toolchain here); Swift
side verified with a clean NOOPiOS simulator build.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…families

Add diagnostic WHOOP GATT family detection
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ght-guard (ryanbr#864)

Both existing battery alerts fire at most once per discharge cycle and then latch — the 15% SoC
alert holds until the cell recovers to 25%, the 24 h predictive alert (ryanbr#713) until the estimate
returns to 36 h. Neither re-arms on anything but a charge, so a strap that just keeps draining goes
silent exactly as the news gets worse. Measured on a 5.0 running R22 deep-data + high-rate IMU:
both had already fired, then nothing across the final descent, and a full night was lost. At the
measured 1.65 %/h that silence ran ~3 h with the app still showing ~6 h of runway.

Two additions, each with its OWN persisted gate so a latched low/runtime alert can never suppress
them.

Critical SoC at 12%, derived from the hardware cutoff rather than picked round. The strap goes dark
near 10% — the last HR sample on the reference run landed 27 min after the 10.9% reading and nothing
below it was ever banked — so the usable band is (10%, 15%]. A conventional 5% is unreachable on this
hardware and 10% is a coin flip. 12% sits mid-band, was reliably reported, and clears the 15% line by
3 pp so it is a genuinely separate crossing. Fired once on the real curve, 90 min before death.

Bedtime night-guard near the LEARNED habitual bedtime (ryanbr#547 model reused, not reinvented), firing
when the strap will not clear wait-until-bed + the night + a margin. Re-arms on leaving the window,
so once per NIGHT rather than once per charge — it speaks when everything else has latched. Cold
start returns silent; no fabricated bedtime.

Underneath both, usableRemainingHours re-anchors the estimate to the ~10% cutoff:
remainingHours * (soc - cutoff) / soc, rearranged so the rate is never recovered. Verified
numerically against computing it directly. The ryanbr#99 clamp carries through conservatively — it only
ever lowers remainingHours, so the usable figure can only be pessimistic.

Added ADDITIVELY on purpose: remainingHours still feeds the Today badge and the Kotlin shared
fixtures, so re-anchoring it in place would have moved a displayed number and broken documented
parity in the same commit. Verified untouched.

Parity checked mechanically, all six constants identical across platforms: cutoffSocPct 10.0,
criticalSocPct 12, criticalRearmAbovePct 25, bedtimeLeadHours 3.0, bedtimeMarginHours 1.0,
bedtimeMinNights 7. Android has no per-notification .timeSensitive; the shared channel is already
IMPORTANCE_HIGH so both escalations heads-up, and setBypassDnd() is deliberately NOT taken since it
needs user-granted policy access. Documented in the Kotlin rather than left as a silent divergence.

The post() change is one defaulted parameter, so every existing call site is unchanged.

Policy is pure and lives in StrandAnalytics, so swift test reaches it with no app, no strap, no
CoreBluetooth. 10/10 CI green.

KNOWN GAP, follow-up immediately: the four new Apple notification strings are not in
Localizable.xcstrings, so they ship English on iOS while the Android twin is translated into all six
locale files. i18n_audit cannot see this — it checks that entries already IN the catalog have every
language, and a String(localized:) literal that never got extracted is invisible to it. That is ryanbr#844
exactly. Two further literals in the same file were already missing before this PR.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…e catalog (ryanbr#866)

Follow-up to ryanbr#864. Resource-only, no code change.

ryanbr#864 added four String(localized:) literals in Strand/System/BatteryNotifier.swift and translated
its Android twin into all five shipped locale files, but the Apple catalog was never updated — so
both escalations shipped English on iOS/macOS in every language. Two further literals in the same
file (the ryanbr#713 runtime alert) were already missing before that PR.

Six entries added, all eight shipped Apple languages:

  Strap battery low
  %@ left on your WHOOP — recharge tonight.
  Charge your WHOOP now
  %lld%% left. The strap stops recording near 10%% — it won't capture tonight unless you charge it.
  Won't last the night
  %@ of recording left, but tonight needs about %@. Charge before bed.

WHY CI WAS GREEN ON ryanbr#864. The audit checks that keys already IN a catalog carry every language. A
String(localized:) literal that was never extracted is not a missing translation, it is a missing
key, and is invisible to it. That is ryanbr#844 exactly; this is the manual patch, not the fix.

KEYS. Derived mechanically from the source literals rather than retyped: String interpolation to
%@, Int to %lld, literal % to %% inside an interpolated string (a non-interpolated one keeps a bare
%, which is why the existing "Your WHOOP is at 100%." is stored unescaped). That mapping is not
assumed — it is the observed encoding of XiaomiBandView.swift:268, whose literal produced the
existing key "%@ in bed · %lld%% efficiency", exercising all three conversions in one string. Keys
stay non-positional and translations use %1$/%2$ where word order moves, matching how the catalog
already stores its two-argument entries.

REGISTER. de, fr and zh-Hans reuse the Android wording. es and pt-PT do not: ryanbr#864 translated those
formally, while this catalog is informal by a wide margin (es tu/tus/tú 762 against su/sus/usted
66; pt-PT teu/tua 689 against seu/sua/você 20) and the battery strings it already ships next to
these are informal. Each language also uses the word for "strap" the catalog already established
for it (Strap, pulsera, bracelet, fascia, correia, браслет, 手环, 手環) rather than importing
Android's. Register and vocabulary only; no numbers move and no Android file is touched.

GRAMMATICAL AGREEMENT. The substituted %@ is BatteryEstimator.label output — "~14h" or "~1h" — so a
body opening with a verb must commit to singular or plural against a value it cannot see, and
because the argument is an opaque string, xcstrings plural variations cannot key on it either. The
three languages that inflect here are recast as label-then-value ("Autonomía restante en tu WHOOP:
%@"). de, fr, ru and both Chinese variants were already agreement-free. Same treatment in the
critical body, where %lld can be anything from 1 to criticalSocPct = 12, so both "Queda 1%" and
"Quedan 12%" occur: it states the level instead.

Two further wording fixes from a final read-through: the fr/it/pt-PT low-battery title placed the
adjective after the strap noun, where it attaches to the strap and — since faible and scarica are
also verb forms — can be read as a sentence about the strap discharging; and the es bedtime body
ended in a feminine clitic with no antecedent, where every other Spanish string in this feature
treats the device as masculine. Both now match how each language already renders the existing "Low
battery" key.

it, ru and zh-Hant are new translations and have NOT been checked by a native speaker.

VERIFICATION. Resource-only, so nothing to compile, and both shipping Apple targets pick it up:
Strand and NOOPiOS each take "- path: Strand" wholesale, excluding only Info.plist, entitlements
and Assets.xcassets, so the catalog and BatteryNotifier.swift land in both. All 10 String(localized:)
literals in that file now resolve to catalog keys. Argument types, positions and literal-percent
counts were checked against every key across 6 keys x 8 languages after each rewrite, so no format
string can garble at substitution. Measured against origin/main: 6 keys added, 0 removed, 0
pre-existing entries modified, and the catalog moves 3259 to 3265 keys with every per-language
missing count unchanged. i18n_audit.py --ci origin/main green; Tools/i18n_extra_locale_baseline.txt
untouched and unaffected.

NOT IN SCOPE. Android hardcodes six battery strings as raw Kotlin literals rather than R.string
(BatteryAlertNotifier.kt:123,124,151,152,163,164), so the runtime, low and full alerts ship English
there in every locale — the same bug in the opposite direction. Filed as ryanbr#867.
ryanbr added a commit that referenced this pull request Aug 25, 2026
A WHOOP 4.0 field log banks two battery samples of 81% thirty seconds
before the strap's own report says 39.2, then stays near 39 for the rest of
the session. Those two are the only samples following a "Reading standard
Battery Level (0x2A19)" line, which appears exactly twice and never again
once the strap is identified.

The 4.0's 0x2A19 is a stub rather than a state of charge (#77), but both
readers asked the family question the wrong way round — `!= WHOOP4` —
against a connectedFamily that defaults to WHOOP4, is written only at
service discovery, and was never cleared per-connection, so between links
it held the PREVIOUS strap's family. Asking that of a guess answers "use
the standard characteristic" for a 4.0. It is the #171 shape the family
resolver's own docs warn about: a family question answered by a
fall-through rather than by evidence.

Not cosmetic — banked samples feed the discharge-slope estimate (#713), so
a phantom 81% at session start reaches the "time left" readout and lands in
the history the open 4.0 drain work reads.

Clear the establishment state per-connection alongside the other flags in
reset(), and route both readers through one pure decision requiring
positive evidence: defer while unknown, custom command for a 4.0, standard
characteristic only for a link positively established as non-4.0.

Deferring is safe: the battery is polled about every 60s, and the
post-connect refresh sits inside an already-established 5/MG branch behind
a 1.5s delay. reset() runs only in connectToDevice() and teardown, never
between discovery and use.

Also corrected the comment claiming that characteristic is a "constant
100". It returned 81 here — plausible enough to pass any range check, which
is why the source and not the value has to be the gate.

Android only. Swift has the identical gate shape but feeds it selectedModel,
which has no generic case, defaults to .whoop4 (the safe branch), and is set
by the service-filtered scan or adopt that reached the strap. The one
residual — willRestoreState with a stale persisted model — is unevidenced
and left for its own issue.

Verified: app-build green both legs; Android build-and-test green; 4 new JVM
tests, one sweeping every (established, family) pair to assert exactly one
reaches the standard characteristic; doc_comment_lint and i18n_audit clean.
Not strap-verified — the 81/39.2 pair above is the reproduction.
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
Refs ryanbr#713

# Conflicts:
#	Strand/App/TabRoute.swift
#	Strand/Liquid/LiquidTodayView.swift
#	Strand/Resources/Localizable.xcstrings
#	Strand/Screens/HomeDashboardContent.swift
#	Strand/Screens/SleepView.swift
#	StrandiOS/App/StrandiOSApp.swift
#	Tools/parity_twin_map.json
#	android/app/src/main/java/com/noop/ui/InsightsScreen.kt
#	android/app/src/main/java/com/noop/ui/SleepScreen.kt
#	android/app/src/main/java/com/noop/ui/SleepWhoopDetailUi.kt
#	android/app/src/main/res/values-de/strings.xml
#	android/app/src/main/res/values-es/strings.xml
#	android/app/src/main/res/values-fr/strings.xml
#	android/app/src/main/res/values-it/strings.xml
#	android/app/src/main/res/values-pl/strings.xml
#	android/app/src/main/res/values-pt-rPT/strings.xml
#	android/app/src/main/res/values-ru/strings.xml
#	android/app/src/main/res/values-zh/strings.xml
#	android/app/src/main/res/values/strings.xml
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
TrapstarKS added a commit to TrapstarKS/troop that referenced this pull request Oct 4, 2026
Refs ryanbr#713

# Conflicts:
#	Tools/parity_dispositions.json
#	Tools/parity_ledger_baseline.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add diagnostic support for additional WHOOP 5 GATT families

2 participants