Skip to content
Open
Changes from all commits
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
177 changes: 103 additions & 74 deletions source/BiometricCollector.mc
Original file line number Diff line number Diff line change
@@ -1,59 +1,104 @@
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
// NO synthetic data on real devices - only shows real measurements
class BiometricCollector {

private var rmssdCalculator;
private var stabilityAnalyzer;

// Current readings
private var heartRate;
private var currentHR;
private var rmssd;
private var cv;

// State tracking
private var tickCount;
private var usingSynthetic;

// Configuration
private const SYNTHETIC_TIMEOUT = 8; // Start synthetic after 8 seconds
private var listenerRegistered;
private var hasReceivedRealData;

function initialize(rmssdCalc, stabilityAnalyz) {
rmssdCalculator = rmssdCalc;
stabilityAnalyzer = stabilityAnalyz;

heartRate = null;
currentHR = null;
rmssd = null;
cv = null;
tickCount = 0;
usingSynthetic = false;
listenerRegistered = false;
hasReceivedRealData = false;

// 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) {
var hr = info.heartRate;
if (hr != null && hr > 0) {
currentHR = hr;
}
}

// 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) {
hasReceivedRealData = true;

// 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
Copy link

Copilot AI Feb 3, 2026

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();

// Fallback to synthetic if no real data
if (!gotRealData && tickCount > SYNTHETIC_TIMEOUT) {
generateSyntheticData();
// If listener not registered, try polling for HR at minimum
if (!listenerRegistered) {
pollSensorFallback();
}

// Update stability analyzer with current RMSSD
Expand All @@ -63,78 +108,50 @@ module Affect {
}
}

// Poll Sensor.getInfo() - the simplest reliable method
private function pollSensor() {
// Fallback polling for HR only (no fake RR intervals)
private function pollSensorFallback() {
var info = Sensor.getInfo();

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

// Get heart rate
if (info has :heartRate && info.heartRate != null && info.heartRate > 0) {
heartRate = info.heartRate;
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;
if (info != null) {
// Get heart rate only - we won't fake RR intervals
var hr = info.heartRate;
if (hr != null && hr > 0) {
currentHR = hr;
}
}

return false;
}

// Generate realistic synthetic data for simulator/testing
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
var baseRR = (60000.0 / heartRate).toNumber();
var variation = (Math.rand() % 60) - 30; // ±30ms natural variation
var rrInterval = baseRR + variation;

// Feed to calculators
rmssdCalculator.addInterval(rrInterval);
rmssd = rmssdCalculator.getRMSSD();
}

// Getters
function getHeartRate() { return heartRate; }
function getHeartRate() { return currentHR; }
function getRMSSD() { return rmssd; }
function getCoefficientOfVariation() { return cv; }
function isSynthetic() { return usingSynthetic; }
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;
return currentHR != null && rmssd != null;
}

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

// Check if we're receiving real HRV data
function hasRealHRVData() {
return hasReceivedRealData && rmssd != null;
}

// Check if HRV is unavailable (for showing appropriate message)
function isHRVUnavailable() {
// After 15 seconds with no real RR data, HRV is unavailable
return tickCount > 15 && !hasReceivedRealData;
}

// 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 @@ -143,13 +160,25 @@ module Affect {

// Reset state
function reset() {
heartRate = null;
currentHR = null;
rmssd = null;
cv = null;
tickCount = 0;
usingSynthetic = false;
hasReceivedRealData = false;
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
Copy link

Copilot AI Feb 3, 2026

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.
}
}
}