Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 90 additions & 40 deletions source/BiometricCollector.mc
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
using Toybox.Sensor;
using Toybox.System;
using Toybox.Math;
using Toybox.Lang;

module Affect {

// Elegant Biometric Collector
// Leonardo's principle: Simplicity is the ultimate sophistication
//
// One simple approach: Poll Sensor.getInfo() every second
// If no real data after timeout, generate synthetic data for testing
// Biometric Collector using real heart beat intervals
// Uses Sensor.registerSensorDataListener() for accurate HRV measurement
// Falls back to synthetic data in simulator only
class BiometricCollector {

private var rmssdCalculator;
Expand All @@ -22,9 +21,11 @@ module Affect {
// State tracking
private var tickCount;
private var usingSynthetic;
private var listenerRegistered;
private var lastRealDataTick;

// Configuration
private const SYNTHETIC_TIMEOUT = 8; // Start synthetic after 8 seconds
private const SYNTHETIC_TIMEOUT = 10; // Start synthetic after 10 seconds with no real data

function initialize(rmssdCalc, stabilityAnalyz) {
rmssdCalculator = rmssdCalc;
Expand All @@ -35,24 +36,76 @@ module Affect {
cv = null;
tickCount = 0;
usingSynthetic = false;
listenerRegistered = false;
lastRealDataTick = 0;

// Enable heart rate sensor
// Register for real heart beat interval data
registerHeartBeatListener();
}

// Register sensor data listener for real RR intervals
private function registerHeartBeatListener() {
try {
// Enable heart rate sensor first
Sensor.setEnabledSensors([Sensor.SENSOR_HEARTRATE]);

// Register for heart beat interval data (real RR intervals!)
var options = {
:period => 1, // 1 second batches
:heartBeatIntervals => {
:enabled => true
}
};
Sensor.registerSensorDataListener(method(:onSensorData) as Lang.Method, options);
listenerRegistered = true;
} catch (e) {
// Sensor might not be available
// Sensor listener not available (older device or simulator)
listenerRegistered = false;
}
}

// Callback for real heart beat interval data
// This receives actual beat-to-beat timing from the optical HR sensor
function onSensorData(sensorData as Sensor.SensorData) {
// Get heart rate from sensor info
var info = Sensor.getInfo();
if (info != null && info has :heartRate && info.heartRate != null && info.heartRate > 0) {
heartRate = info.heartRate;
}

// Process real heart beat intervals
if (sensorData has :heartRateData && sensorData.heartRateData != null) {
var hrData = sensorData.heartRateData;
if (hrData has :heartBeatIntervals && hrData.heartBeatIntervals != null) {
var intervals = hrData.heartBeatIntervals;
if (intervals.size() > 0) {
usingSynthetic = false;
lastRealDataTick = tickCount;

// Feed each real RR interval to the calculator
for (var i = 0; i < intervals.size(); i++) {
var rrInterval = intervals[i];
if (rrInterval != null && rrInterval > 0) {
rmssdCalculator.addInterval(rrInterval);
}
}
rmssd = rmssdCalculator.getRMSSD();
}
}
}
}

// Called every second by the view timer
function update() {
Comment on lines 95 to 96

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says update() is called every second, but AffectView.onTick() calls biometricCollector.update() on a modulo of the animation phase (~every ~1.25s with current constants). Consider updating this comment (or the cadence) so the time-based logic like SYNTHETIC_TIMEOUT remains interpretable.

Copilot uses AI. Check for mistakes.
tickCount++;

// Try to get real sensor data
var gotRealData = pollSensor();
// If listener not registered, try polling approach
if (!listenerRegistered) {
pollSensorFallback();
}

// Fallback to synthetic if no real data
if (!gotRealData && tickCount > SYNTHETIC_TIMEOUT) {
// Fallback to synthetic if no real data for a while (simulator)
if ((tickCount - lastRealDataTick) > SYNTHETIC_TIMEOUT) {

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update() always falls back to generateSyntheticData() after SYNTHETIC_TIMEOUT based solely on lastRealDataTick. On devices where the heartbeat-interval listener is unsupported (or intervals aren’t provided), lastRealDataTick never advances, so the widget will start injecting synthetic RR/HR on real hardware and corrupt RMSSD/HR. Consider gating synthetic generation strictly to the simulator (or only when no real HR is available) and avoid mixing synthetic intervals into real sessions.

Suggested change
// Fallback to synthetic if no real data for a while (simulator)
if ((tickCount - lastRealDataTick) > SYNTHETIC_TIMEOUT) {
// Fallback to synthetic only when no real HR is available
// and we haven't seen real interval data for a while (simulator, or no sensor)
if ((tickCount - lastRealDataTick) > SYNTHETIC_TIMEOUT && heartRate == null) {

Copilot uses AI. Check for mistakes.
generateSyntheticData();
}

Expand All @@ -63,48 +116,34 @@ module Affect {
}
}

// Poll Sensor.getInfo() - the simplest reliable method
private function pollSensor() {
// Fallback polling for devices where listener doesn't work
private function pollSensorFallback() {
var info = Sensor.getInfo();

if (info == null) {
return false;
return;
}

// Get heart rate
// Get heart rate at minimum
if (info has :heartRate && info.heartRate != null && info.heartRate > 0) {
heartRate = info.heartRate;

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pollSensorFallback() updates heartRate but never updates lastRealDataTick (or clears usingSynthetic). This guarantees the synthetic timeout will trigger even while real HR is coming in, and once synthetic starts it will overwrite real HR in generateSyntheticData(). Update lastRealDataTick when real HR is observed (or introduce a separate “lastHeartRateTick”) and ensure the synthetic path can’t override real HR readings.

Suggested change
heartRate = info.heartRate;
heartRate = info.heartRate;
// Treat polled heart rate as real data to prevent synthetic override
usingSynthetic = false;
lastRealDataTick = tickCount;

Copilot uses AI. Check for mistakes.
usingSynthetic = false;

// Generate RR interval from HR (approximate but works)
// RR = 60000 / HR (in milliseconds)
var estimatedRR = (60000.0 / heartRate).toNumber();

// Add small natural variation
var variation = (Math.rand() % 40) - 20; // ±20ms
var rrInterval = estimatedRR + variation;

// Feed to RMSSD calculator
rmssdCalculator.addInterval(rrInterval);
rmssd = rmssdCalculator.getRMSSD();

return true;
}

return false;
}

// Generate realistic synthetic data for simulator/testing
// Generate realistic synthetic data for simulator/testing only
private function generateSyntheticData() {
usingSynthetic = true;

// Slowly varying HR between 60-80 bpm
var phase = tickCount * 0.05;
heartRate = 70 + (Math.sin(phase) * 10).toNumber();

// RR interval with realistic HRV variation
// Simulate realistic HRV with larger natural variation
// Real HRV has RMSSD typically 20-80ms in healthy adults
var baseRR = (60000.0 / heartRate).toNumber();
var variation = (Math.rand() % 60) - 30; // ±30ms natural variation

// Use larger variation to simulate real HRV (±50ms gives RMSSD ~40-50ms)
var variation = (Math.rand() % 100) - 50;
var rrInterval = baseRR + variation;

// Feed to calculators
Expand All @@ -120,21 +159,19 @@ module Affect {
function getTickCount() { return tickCount; }

// Check if we have enough data for meaningful display
// Requires both HR and valid RMSSD (enough RR intervals collected)
function hasData() {
return heartRate != null && rmssd != null;
}

// Check if HR is available (for progress indication)
// Check if HR is available
function hasHeartRate() {
return heartRate != null;
}

// Get RMSSD readiness as percentage (0-100)
// Based on how many RR intervals collected vs minimum needed
function getReadinessPercent() {
var intervalCount = rmssdCalculator.getIntervalCount();
var minNeeded = 10; // MIN_INTERVALS from RMSSDCalculator
var minNeeded = 10;
if (intervalCount >= minNeeded) {
return 100;
}
Expand All @@ -148,8 +185,21 @@ module Affect {
cv = null;
tickCount = 0;
usingSynthetic = false;
lastRealDataTick = 0;
rmssdCalculator.reset();
stabilityAnalyzer.reset();
}

// Clean up sensor listener
function cleanup() {
if (listenerRegistered) {
try {
Sensor.unregisterSensorDataListener();
} catch (e) {
// Ignore cleanup errors
}
listenerRegistered = false;
}
Comment on lines +172 to +181

Copilot AI Feb 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A cleanup() API was added, but there are currently no call sites in the codebase (e.g., AffectView.onHide() / AffectApp.onStop()), so the sensor listener may remain registered after the widget is hidden and keep the HR sensor active unnecessarily. Wire cleanup() into the view/app lifecycle (and consider unregistering during reset() if applicable).

Copilot uses AI. Check for mistakes.
}
}
}