Skip to content

hrm: add HRV support for GH3X2X (obelix)#1670

Open
karthakon wants to merge 4 commits into
coredevices:mainfrom
karthakon:hrv-gh3x2x
Open

hrm: add HRV support for GH3X2X (obelix)#1670
karthakon wants to merge 4 commits into
coredevices:mainfrom
karthakon:hrv-gh3x2x

Conversation

@karthakon

@karthakon karthakon commented Jul 7, 2026

Copy link
Copy Markdown

Implements HRV support per the plan in #1630.

Three commits: applib/SDK surface (HealthEventHRVUpdate, health_service_peek_hrv_ppi_ms(), health_service_set_hrv_sample_period()), the feature-gated driver/manager work, and the obelix enable + nonfree bump.

hrm_enable() now takes the requested HRMFeature set (aligned with the direction of #1607; the low_latency parameter is reserved and unused here). hrm_manager passes the union of active subscriber features to the driver and restarts the sensor when that set changes, so HRV is only sampled while an app holds an HRV sample period. HRV results are broadcast as HealthEventHRVUpdate.

Tested on a retail PT2: RR intervals flow end to end to a watchapp built against an SDK from this tree. First full-night run (~8h): nightly RMSSD 39 ms; same-night Garmin Instinct Crossover on the other wrist reported overnight HRV 26 ms (both optical PRV; difference under investigation, more nights being collected). Battery 63% -> 28% overnight with continuous HRV sampling and the test app recording. Per-minute session detail from this run was lost to a UI mishap in the test app, not a firmware issue. HR-only operation is unchanged when no app requests HRV. Test app: https://github.com/karthakon/hrv-monitor

Note: my test unit's touchscreen failed during this period, including on stock firmware, PRF, and after factory reset — a hardware fault unrelated to these changes (nothing here touches the touch stack). Mentioned for full transparency.

Companion nonfree PR (required — firmware calls into the HRV algorithm result-report filled there): coredevices/pebbleos-nonfree#6
Fixes #1630.

@karthakon

Copy link
Copy Markdown
Author

Night 2 (2026-07-07→08) — release-config firmware, duty-cycled sampling

Two things changed from night 1, so these numbers are not apples-to-apples:

Both battery and RMSSD move for two reasons at once — flagging so nothing here is read as a controlled comparison.

Battery (headline):

Night Config Sampling Battery Duration Rate
1 dev (debug UART on) continuous 63% → 28% ~7h ~5%/hr
2 release duty-cycled 3/15 84% → 72% (00:46–08:31) 7h45m ~1.5%/hr

RR / HRV (duty-cycled → far fewer accepted beats):

  • RMSSD 39 ms, SDNN 86 ms, mean PPI 891 ms
  • 400 beats accepted / 2788 rejected over the session

Reference (Garmin Instinct Crossover, other wrist): overnight avg HRV 45 ms, highest 5-min avg 64 ms.

The rejection rate is high (expected for wrist optical + duty-cycling); accepted-beat count is low enough that I'd treat the single-night RMSSD as indicative only. Collecting more nights.

@karthakon

Copy link
Copy Markdown
Author

Status update — pausing hardware testing

Third overnight test. Battery figures across all three runs, for context:

  • Night 1 — dev firmware (debug UART on), continuous sampling: 63% → 28% overnight (~35% drop; highest drain of the three)
  • Night 2 — release firmware (CONFIG_RELEASE=y), duty-cycled sampling: 84% → 72% over 7h45m ≈ 1.5%/hr
  • Night 3 — release firmware (CONFIG_RELEASE=y), continuous sampling: 43% → 26% over 6h37m ≈ 2.6%/hr

So debug UART was the dominant cost, and on release config the continuous-vs-duty-cycled difference accounts for roughly ~1%/hr.

Also confirmed this run:

  • Sensor gating is working: the HRM green LED is off whenever no client is subscribed, and only powers on while an HRV-consuming app is in the foreground — the subscriber-gated path is holding, no idle drain from the HRV additions.
  • PPI delivery through the public HealthService path continues to work (RMSSD/SDNN/PPI all read out app-side).

I'll be away from my test hardware for a few weeks, so the draft stays as-is until I'm back — will follow up with a clean continuous-vs-duty-cycle battery comparison then.

@karthakon
karthakon force-pushed the hrv-gh3x2x branch 2 times, most recently from 7d87042 to dd32839 Compare July 16, 2026 14:40
@karthakon

Copy link
Copy Markdown
Author

Transport validation on hardware (Pebble Time 2, obelix@pvt)

Firmware: v4.29.0-3-gd60dca8f (this branch, rebased onto upstream/main, slot 1)
Test app: hrv-logger @ 78aa4da — raw transport only, no filtering/HRV math. On-screen readout, no pebble logs dependency.

3-minute awake test at rest:

  • PPI (last): 559 ms; Range: 484–680 ms — physiological and consistent with on-screen HR of 101 bpm (~593 ms mean interval)
  • HRV events: 308 over 3 min (~1.7/s)
  • HR events: 184 over 3 min

Subscribe/unsubscribe:

  • Long-press start → Sampling ON, HRV events flow immediately
  • Long-press stop → Sampling OFF, HRV event count freezes (308, no further events)

HR regression check:

  • Watch Health HR setting: 10-minute interval
  • After unsubscribe, HR resumed on its normal schedule (~6 min later, mid-cycle), HR event count climbing, HRV count still frozen

All three transport criteria met: correctly-scaled PPI on HealthEventHRVUpdate while subscribed, clean subscribe/unsubscribe, no HR regression.

@karthakon
karthakon marked this pull request as ready for review July 17, 2026 21:20
@karthakon

Copy link
Copy Markdown
Author

Overnight validation + sparsity root cause (100 Hz fix, 7eb5f49c)

The fix (7eb5f49c, src/fw/drivers/hrm/gh3x2x.c): the Goodix HRV vendor config declares fs = 100, but the driver was registering the HRV function at GH3X2X_HR_SAMPLING_RATE (25 Hz) — the algorithm ran at a quarter of its design rate. The change adds a dedicated #define GH3X2X_HRV_SAMPLING_RATE 100 and uses it for HRV registration only; HR sampling stays at 25 Hz. One define plus one line.

Following the transport validation above, overnight testing had exposed the problem the awake 3-minute test didn't: HRV intervals arrived at only ~2/min overnight with a ~99% jump-rejection rate — too sparse for meaningful RMSSD. The 25→100 Hz mismatch above was the root cause.

Effect of the fix. Intervals are pulled off HealthEventHRVUpdate by hrv-logger (raw transport, no filtering) for the awake counts and hrv-monitor for the overnight session — the apps are just the readout for what the firmware delivers.

Metric 25 Hz 100 Hz
Awake delivery (5 min sitting) ~92 intervals/min
Overnight delivery ~2/min 64/min (38,244 intervals / 9h57m)
Overnight rejection ratio ~50% 1.3%
Overnight RMSSD 63 ms (physiologically sane)

Battery. At the fixed 100 Hz HRV registration rate, overnight draw with continuous sampling is ~0.9 %/hr (release build, static screen), vs ~0.4–0.8 %/hr at 25 Hz — roughly +4–5% per night for the rate change. For nightly recording this is a meaningful cost: a user seeing ~4 weeks of standby would see it roughly halved. Flagging it as a real trade-off of running the HRV function at its design rate.

karthakon and others added 4 commits July 23, 2026 10:39
Add HealthEventHRVUpdate carrying peak-to-peak intervals (PPI) from
the HRM, a health_service_peek_hrv_ppi_ms() accessor for the most
recent value, and health_service_set_hrv_sample_period() which lets
an app request HRV collection the same way it requests a heart rate
sample period.

The last cached PPI lives in HealthServiceState rather than an
applib static so it is safe in app code paths.

Part of coredevices#1630.

Signed-off-by: karthakon <33638614+karthakon@users.noreply.github.com>

Co-Authored-By: Claude <noreply@anthropic.com>
Change hrm_enable() to take the requested HRMFeature set (matching
the direction of coredevices#1607) and a low_latency flag (reserved, unused
here). hrm_manager computes the union of active subscriber features,
passes it to the driver, and restarts the sensor when the needed
feature set changes, so HRV is only collected while a subscriber
holds HRMFeature_HRV.

In the GH3X2X driver, when HRV is requested: enable the HRV function,
mirror the HR PPG channel map onto it (the shipped register config
only maps channels for HR/SpO2/ADT, so HRV otherwise comes up with
zero channels and never receives frames), and run it at the HR
sampling rate. gh3x2x_hrv_result_report() forwards each valid RR
interval from the Goodix HRV algorithm to hrm_manager.

hrm_manager broadcasts HRV results as HealthEventHRVUpdate so apps
subscribed through health_service receive them without a raw HRM
session. Everything is behind the new HRM_HRV Kconfig, default n.

Part of coredevices#1630.

Signed-off-by: karthakon <33638614+karthakon@users.noreply.github.com>

Co-Authored-By: Claude <noreply@anthropic.com>
Turn on CONFIG_HRM_HRV for obelix (PT2) and bump pebbleos-nonfree to
pick up the GH3X2X HRV algorithm hook (companion PR).

Tested on a retail PT2: RR intervals flow end to end from the sensor
to a watchapp via HealthEventHRVUpdate only while the app holds an
HRV sample period; HR-only operation is unchanged when no app
requests HRV.

Closes coredevices#1630.

Signed-off-by: karthakon <33638614+karthakon@users.noreply.github.com>

Co-Authored-By: Claude <noreply@anthropic.com>
The Goodix HRV algorithm's vendor config declares fs = 100, but the
driver was registering the HRV function at the HR rate (25 Hz), and
GH3x2xHrvAlgoInit overwrites the config fs with the function's actual
rate. Running the algorithm at a quarter of its design rate is the
leading suspect for the sparse overnight RRI stream (~2/min observed
vs ~100/min expected). HR remains at 25 Hz; the rate is per-function.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: karthakon <33638614+karthakon@users.noreply.github.com>
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.

Expose HRV/inter-beat intervals from GH3026 through HealthService

1 participant