Low-level C firmware skeleton for a smart wellness ring with secure BLE.
- ARM Cortex-M BLE SoC
- RTOS optional; this skeleton uses a simple super-loop
- BLE stack is vendor-provided and integrated through
ble_adapter.* - Sensors: optical HR/SpO2, accelerometer, skin temperature, battery fuel gauge
- BLE LE Secure Connections only
- Bonding enabled
- MITM protection enabled where possible
- Reject legacy pairing
- Encrypt authenticated characteristics
- Store bonds in secure flash/NVM
- Application-level message counters for replay resistance
This is a portable skeleton, not tied to a vendor SDK. Add your vendor SDK include paths and implement platform_hal.* + ble_adapter.* using your chip's BLE stack.
mkdir build && cd build
cmake ..
make
ctest # runs host-mode unit tests
If you don't have CMake, you can also build directly:
gcc -std=c99 -Wall -Wextra -Iinclude -Ible -o wellness_ring_fw \
src/main.c src/sensors.c src/wellness_model.c src/platform_hal_stub.c ble/ble_adapter.c \
src/activity_model.c src/sleep_model.c src/recovery_model.c src/temperature_model.c \
src/cycle_model.c src/sensors_extended.c
Everything below is new: activity, sleep, recovery, temperature-trend, and (opt-in) cycle tracking, plus honest stubs for two things this hardware genuinely can't do. Safety features (fall detection, SOS) were explicitly excluded from this pass by product decision - see the full metrics discussion in chat history for the reasoning on what a smart ring can/should track.
All of this lives in include/extended_metrics.h plus one new .c file per category (activity_model.c, sleep_model.c, recovery_model.c, temperature_model.c, cycle_model.c, sensors_extended.c), wired into main.c's loop alongside the existing HR/SpO2/step/HRV pipeline. None of these new metrics are exposed over BLE yet - ble_adapter_send_measurement() only carries the original wellness_sample_t. Adding GATT characteristics for the new data is a follow-up task, not done here.
Activity (activity_model.c) - distance, calories, activity-type recognition (sedentary/walking/running/cycling via step cadence + accelerometer motion variance), standing/inactive time, floors climbed (barometer-derived). Distance and calorie formulas are rough population-average estimates (fixed stride length, ~24 kcal/kg/day BMR approximation), not clinically validated - tune against real reference data before shipping. "Standing time" is a best-effort label for "not currently walking/running/cycling" - a finger-worn ring's accelerometer fundamentally cannot distinguish sitting from standing the way a hip or thigh-worn sensor could.
- Fixed a real precision bug during development: the original per-call
(step_delta * stride) / 100integer division truncated to exactly 0 for the realistic case of one step per tick, so distance and active calories would never have accumulated at all. Now uses remainder-preserving accumulators (same technique as the existing resting-calorie counter), verified intests/test_activity_model.c.
Sleep (sleep_model.c) - onset/wake detection (20 min quiet → asleep, 5 min sustained motion → awake), sleep stage cycling, efficiency, restlessness count, apnea indicator (repeated SpO2 dips below 90%). The stage cycling (light/deep/awake/REM) is a simplified fixed-pattern stub based on elapsed time within a nominal 90-minute cycle, not EEG-validated staging - real sleep staging needs more signals (and more sophisticated modeling) than a ring's accelerometer + PPG can provide alone. Treat stages as illustrative, not diagnostic.
Recovery (recovery_model.c) - a 0-100 readiness score blending HRV (40%), sleep efficiency (40%), and resting HR vs. a slow-moving personal baseline (20%), plus guided-breathing session tracking (start/tick/end, with average stress score during the session). These weights are a reasonable starting point, not a clinically validated formula - same caveat as the stress-score calibration.
Temperature (temperature_model.c) - tracks a slow-moving (EMA) personal baseline from one reading per day and reports deviation from it; needs 3 days of history before it's considered meaningful.
Cycle tracking (cycle_model.c) - opt-in only (cycle_model_init(false) by default in main.c - this infers sensitive personal health data and doesn't apply to every user). Uses the well-established post-ovulatory basal-temperature rise to estimate cycle day, predicted next period, and an ovulation window. This is a simple threshold heuristic on top of a real physiological signal, not a medical- or contraceptive-grade algorithm - it doesn't use period start dates, cervical mucus, LH tests, or the other signals real fertility-tracking methods combine. Self-corrects after the first full cycle is observed but should be presented to users as a rough estimate.
Body composition & respiration, device health, environmental (sensors_extended.c) - respiratory rate and device firmware/charging status are simple stub reads, same category as the existing HR/SpO2 stubs. Body fat percentage always reports 0 (unavailable): it needs bioimpedance electrodes this ring's sensor set doesn't have. Ambient light, altitude, and UV index are placeholder reads pending real photodiode/barometer/UV-sensor hardware.
Honest hardware-limited stubs - sensors_ecg_supported() always returns false and sensors_read_ecg_rhythm()/sensors_read_blood_pressure_trend() always return "unsupported" (RING_ERR_INVALID), on purpose. ECG needs multi-point electrode contact (e.g. a finger from each hand) a single-contact ring can't provide; PPG-only blood-pressure trend estimation needs either a pulse-transit-time reference (a second sensor site) or per-user cuff calibration this device has no way to collect. Returning a fabricated reading for either would be worse than clearly reporting the capability doesn't exist on this hardware.
All of the above have unit tests in tests/ (test_activity_model.c, test_sleep_model.c, test_recovery_model.c, test_cycle_model.c, test_temperature_model.c) with reference values computed independently (in Python, outside this codebase) rather than derived from the C implementation itself, so the tests actually check the math.
Implemented and tested (host/simulator build):
- HRV-based stress tracking (
wellness_model.c): ingests beat-to-beat (RR) intervals as they arrive (via the newsensors_read_rr_interval()) and computes RMSSD (root mean square of successive differences), the standard short-term HRV metric, over a rolling window of the most recent ~20 beats. Lower HRV maps to a higherstress_score(0-100). Exposed as three new fields onwellness_sample_t:hrv_rmssd_ms,stress_score, andhrv_valid(false until enough beats have been collected - stress scores need a handful of beats to mean anything, not a single reading). Tested intests/test_stress_model.cagainst independently hand-computed RMSSD reference values for both a low-variability ("stressed") and high-variability ("relaxed") RR sequence, plus edge cases (not-enough-beats-yet, a zero/invalid RR interval being ignored rather than corrupting the running calculation).- The RMSSD-to-stress-score calibration (15ms=100, 100ms=0) is a reasonable default based on typical adult resting HRV ranges, not a clinical calibration. Real deployments should tune
STRESS_RMSSD_LOW_MS/STRESS_RMSSD_HIGH_MSagainst ECG-validated reference data for the target population before presenting a stress number to users as authoritative. sensors_read_rr_interval()insensors.cis, like the other sensor reads, a stub: it synthesizes a plausible ~72bpm beat signal with small jitter so the model has something realistic to run against on host builds. Real hardware gets RR intervals from the optical HR sensor's own PPG beat-detection algorithm (the same one that already derivesheart_rate_bpm) - this needs to be replaced with actual beat timestamps from your sensor driver.
- The RMSSD-to-stress-score calibration (15ms=100, 100ms=0) is a reasonable default based on typical adult resting HRV ranges, not a clinical calibration. Real deployments should tune
- Fixed four stale
#includes left over from a header consolidation (several separate headers were merged intoinclude/ring.h, butble_adapter.h,ble_adapter.c,sensors.c, andtests/test_wellness_model.cstill referenced the old, now-nonexistent filenames -ring_types.h,ring_config.h,platform_hal.h,sensors.h,wellness_model.h- which meant nothing actually compiled). - Debounced, hysteresis + EMA-smoothed step detection (
wellness_model.c), with a unit test intests/test_wellness_model.cthat simulates walking and a noise burst. - Application-level replay protection on BLE control commands (
main.c): each command must carry a strictly increasing 32-bit counter; stale/replayed counters are rejected. This was listed as a security goal in the original skeleton but not implemented. - A working "sync time" control command that actually sets an RTC (
platform_rtc_set/now), instead of being a no-op stub. include/health_types.h(previouslyhealth_types_uploaded.h, which contained an emptyenum {}- invalid C that didn't compile, and wasn't referenced anywhere). It's now a valid, optional extension point for occasional health data points (sleep episodes, blood pressure) alongside the always-onwellness_sample_t.- Removed a dead/misleading step counter in
sensors.cthat was always 0 and silently overwritten by the wellness model. - Battery percentage is clamped defensively to 0-100.
Still stubs that need real hardware/vendor SDK before shipping:
platform_rng_get()inplatform_hal_stub.creturns a fixed, predictable byte pattern. This is fine for host builds but must be replaced with the SoC's hardware TRNG before use - it currently feeds BLE LE Secure Connections key generation, so shipping it as-is would break the security model entirely.- HR/SpO2/skin-temp/RR-interval values in
sensors.care hardcoded or synthetic placeholders; real optical PPG and thermistor driver code and signal-processing algorithms (e.g. FFT-based HR extraction, PPG beat detection, ambient-light rejection) are chip/sensor-specific and need to be written against your actual sensor's datasheet. ble_adapter.clogs intended behavior instead of calling a real BLE stack; every function needs to be rewritten against your vendor's SDK (Nordic SoftDevice, ESP-IDF NimBLE, etc.).- Bond/key storage (
platform_secure_store_*) is a no-op; needs real encrypted flash/NVM backing. - The stress-score calibration constants need validation against real HRV reference data (see above) before being presented to users as more than an experimental/relative indicator.
- Body fat % has no bioimpedance hardware to read from (
sensors_read_body_composition()always reports 0/unavailable); ambient light, altitude, and UV index insensors_read_environmental()are placeholder values pending real sensor hardware. - ECG and blood-pressure-trend are deliberately unsupported (see "Honest hardware-limited stubs" above) - this isn't a TODO to fill in with the current sensor set, it needs different/additional hardware.
- None of the new activity/sleep/recovery/temperature/cycle data is exposed over BLE yet - needs new GATT characteristic design.
- SoC nRF54L15
- Antenna Johanson 2450AT07A0100
- HF crystal32MHz, load capacitance CL = 6–9pF
- LF clockInternal LFRC (±250ppm after periodic HFXO calibration) add an external 32.768kHz LFXO
- RF matching networkPi (shunt-series-shunt) footprint, values TBD per device
- NFCUse the nRF54L15's built-in NFC-A tag hardware
- PMIC/chargernPM1304 (not nPM1300)
- BatterySmall curved rechargeable Li-poly cell (~15–30mAh), charged via pogo-pin contacts
- PPG AFE (HR/SpO2/HRV)MAX86141 (dual-channel optical AFE)
- AccelerometerBosch BMA400
- Skin temperatureNTC thermistor + the nRF54L15's built-in 14-bit SAADC
- Barometer (floors climbed)Bosch BMP390 or similar