feat: implement Haptika PPG and Watch V1

This commit is contained in:
Sacha
2026-07-31 16:19:42 +02:00
parent 6b7d4e0728
commit b9930b7378
249 changed files with 46475 additions and 122 deletions
+4
View File
@@ -60,6 +60,10 @@ yarn-error.log
**/Pods/ **/Pods/
/vendor/bundle/ /vendor/bundle/
# Swift Package Manager
**/.build/
**/.swiftpm/
# Temporary files created by Metro to check the health of the file watcher # Temporary files created by Metro to check the health of the file watcher
.metro-health-check* .metro-health-check*
+831 -23
View File
@@ -1,44 +1,852 @@
/** import React, { useCallback, useEffect, useRef, useState } from 'react';
* Sample React Native App import { ActivityIndicator, StatusBar, StyleSheet, View } from 'react-native';
* https://github.com/facebook/react-native import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
*
* @format
*/
import { NewAppScreen } from '@react-native/new-app-screen'; import { colors } from './src/theme';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native'; import { OnboardingScreen } from './src/features/onboarding/OnboardingScreen';
import { import {
SafeAreaProvider, onboardingRepository,
useSafeAreaInsets, type OnboardingAnswers,
} from 'react-native-safe-area-context'; } from './src/features/onboarding/onboardingRepository';
import { CardiacMirrorScreen } from './src/features/cardiacMirror/CardiacMirrorScreen';
import { MyCoherenceScreen } from './src/features/coherence/MyCoherenceScreen';
import { coherenceSessionRepository } from './src/features/coherence/coherenceSessionRepository';
import {
HapticWaveExerciseScreen,
type HapticExerciseSummary,
} from './src/features/cardiacMirror/HapticWaveExerciseScreen';
import {
getRegulationProtocol,
recommendRegulationProtocol,
type HapticRegulationProtocolKind,
} from './src/features/cardiacMirror/regulationProtocols';
import { compareRegulationMeasurements } from './src/features/cardiacMirror/regulationComparison';
import { regulationSessionRepository } from './src/features/cardiacMirror/regulationSessionRepository';
import type {
RegulationComparison,
RegulationSession,
} from './src/features/cardiacMirror/regulationSessionTypes';
import { usePPGCamera } from './src/features/ppg/camera/usePPGCamera';
import { evaluateHealthKitContext } from './src/features/ppg/health/healthContextEvaluator';
import { healthKitService } from './src/features/ppg/health/healthKitService';
import type { HealthKitContext } from './src/features/ppg/health/healthTypes';
import { PPG_ALGORITHM_VERSION } from './src/features/ppg/measurement/measurementConfiguration';
import { usePPGMeasurement } from './src/features/ppg/measurement/usePPGMeasurement';
import { PPGFailureScreen } from './src/features/ppg/screens/PPGFailureScreen';
import { PPGHistoryScreen } from './src/features/ppg/screens/PPGHistoryScreen';
import { PPGMeasurementScreen } from './src/features/ppg/screens/PPGMeasurementScreen';
import { PPGPreparationScreen } from './src/features/ppg/screens/PPGPreparationScreen';
import { PPGResultScreen } from './src/features/ppg/screens/PPGResultScreen';
import { calculatePotentialStress } from './src/features/ppg/stress/potentialStressCalculator';
import type { BaselineCalculationResult } from './src/features/ppg/stress/baselineTypes';
import { baselineRepository } from './src/features/ppg/storage/baselineRepository';
import { acquisitionPreferencesRepository } from './src/features/ppg/storage/acquisitionPreferencesRepository';
import { measurementRepository } from './src/features/ppg/storage/measurementRepository';
import { settingsRepository } from './src/features/ppg/storage/settingsRepository';
import type {
DataRetentionPolicy,
MeasurementSelfReport,
StoredMeasurement,
} from './src/features/ppg/storage/storageTypes';
import {calculateWatchBaseline, type WatchBaselineV1} from './src/features/watch/watchBaseline';
import {watchRepository} from './src/features/watch/watchRepository';
import {watchSyncCoordinator} from './src/features/watch/watchService';
import type {
WatchConnectionState,
WatchMeasurementSummaryV1,
WatchRegulationSessionV1,
WatchSyncEnvelopeV1,
WatchTombstoneV1,
} from './src/features/watch/watchTypes';
function App() { type AppView = 'measurement' | 'history' | 'cardiacMirror' | 'hapticExercise' | 'myCoherence';
const isDarkMode = useColorScheme() === 'dark'; type FinalizationState = 'idle' | 'saving' | 'failed';
type SelfReportState = 'idle' | 'saving' | 'saved' | 'failed';
type OnboardingStatus = 'loading' | 'required' | 'completed';
const EMPTY_WATCH_CONNECTION: WatchConnectionState = {
supported: false,
paired: false,
watchAppInstalled: false,
reachable: false,
activationState: 'notActivated',
latestEventId: null,
latestEventAt: null,
};
function createMeasurementId(): string {
return `ppg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function App(): React.JSX.Element {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> <StatusBar
<AppContent /> barStyle="light-content"
translucent
backgroundColor="transparent"
/>
<SafeAreaView
style={styles.safeArea}
edges={['right', 'bottom', 'left']}>
<HaptikaApp />
</SafeAreaView>
</SafeAreaProvider> </SafeAreaProvider>
); );
} }
function AppContent() { function HaptikaApp(): React.JSX.Element {
const safeAreaInsets = useSafeAreaInsets(); const cameraPermission = usePPGCamera();
const measurement = usePPGMeasurement();
const [onboardingStatus, setOnboardingStatus] =
useState<OnboardingStatus>('loading');
const [onboardingAnswers, setOnboardingAnswers] = useState<
OnboardingAnswers | undefined
>();
const [appView, setAppView] = useState<AppView>('measurement');
const [includeHealthKit, setIncludeHealthKit] = useState(false);
const [sensitiveContact, setSensitiveContact] = useState(true);
const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>(
'front',
);
const [atRestSelection, setAtRestSelection] = useState<boolean | null>(null);
const [history, setHistory] = useState<StoredMeasurement[]>([]);
const [watchHistory, setWatchHistory] = useState<WatchMeasurementSummaryV1[]>([]);
const [watchRegulationSessions, setWatchRegulationSessions] = useState<WatchRegulationSessionV1[]>([]);
const [watchBaseline, setWatchBaseline] = useState<WatchBaselineV1 | null>(null);
const [watchConnection, setWatchConnection] = useState<WatchConnectionState>(EMPTY_WATCH_CONNECTION);
const [saveMindAndBodyWorkouts, setSaveMindAndBodyWorkouts] = useState(true);
const [retentionPolicy, setRetentionPolicy] =
useState<DataRetentionPolicy>('forever');
const [baselineProgress, setBaselineProgress] =
useState<BaselineCalculationResult | null>(null);
const [currentMeasurement, setCurrentMeasurement] =
useState<StoredMeasurement | null>(null);
const [exerciseProtocol, setExerciseProtocol] =
useState<HapticRegulationProtocolKind>('coherence55');
const [pendingProtocolIntent, setPendingProtocolIntent] =
useState<HapticRegulationProtocolKind | null>(null);
const [regulationBeforeMeasurement, setRegulationBeforeMeasurement] =
useState<StoredMeasurement | null>(null);
const [pendingRegulationSession, setPendingRegulationSession] =
useState<RegulationSession | null>(null);
const [pendingCoherenceSessionId, setPendingCoherenceSessionId] = useState<
string | null
>(null);
const [regulationComparison, setRegulationComparison] =
useState<RegulationComparison | null>(null);
const [regulationSessions, setRegulationSessions] = useState<
RegulationSession[]
>([]);
const [selfReportState, setSelfReportState] =
useState<SelfReportState>('idle');
const [finalizationState, setFinalizationState] =
useState<FinalizationState>('idle');
const finalizingResultRef = useRef(false);
const measurementAtRestRef = useRef(false);
const postRegulationMeasurementRef = useRef(false);
const refreshHistory = useCallback(async (): Promise<void> => {
setHistory(await measurementRepository.getAll());
}, []);
const refreshWatchData = useCallback(async (): Promise<void> => {
const [measurements, sessions] = await Promise.all([
watchRepository.getMeasurements(),
watchRepository.getRegulationSessions(),
]);
setWatchHistory(measurements);
setWatchRegulationSessions(sessions);
setWatchBaseline(calculateWatchBaseline(measurements));
setWatchConnection(await watchSyncCoordinator.connectionState());
}, []);
const refreshBaseline =
useCallback(async (): Promise<BaselineCalculationResult> => {
const result = await baselineRepository.calculate({
measurementHourLocal: new Date().getHours(),
cameraPosition,
});
setBaselineProgress(result);
return result;
}, [cameraPosition]);
const refreshRegulationSessions = useCallback(async (): Promise<void> => {
setRegulationSessions(await regulationSessionRepository.getAll());
}, []);
useEffect(() => {
onboardingRepository
.get()
.then(state => {
if (state !== null) {
setOnboardingAnswers(state);
}
setOnboardingStatus(
state?.completed === true ? 'completed' : 'required',
);
})
.catch(() => setOnboardingStatus('required'));
settingsRepository.get().then(settings => {
setIncludeHealthKit(settings.includeHealthKitContext);
setSaveMindAndBodyWorkouts(settings.saveMindAndBodyWorkouts);
watchSyncCoordinator.updatePreferences({
schemaVersion: 1,
saveMindAndBodyWorkouts: settings.saveMindAndBodyWorkouts,
protocolCatalogVersion: '1',
updatedAt: new Date().toISOString(),
});
});
acquisitionPreferencesRepository.get().then(preferences => {
setCameraPosition(preferences.cameraPosition);
setSensitiveContact(preferences.sensitiveContact);
});
refreshHistory();
refreshBaseline();
refreshRegulationSessions();
refreshWatchData();
watchSyncCoordinator.start(refreshWatchData);
measurementRepository.getRetentionPolicy().then(setRetentionPolicy);
const connectionTimer = setInterval(() => {
watchSyncCoordinator.connectionState().then(setWatchConnection).catch(() => undefined);
}, 2_000);
return () => {
clearInterval(connectionTimer);
watchSyncCoordinator.stop();
};
}, [refreshBaseline, refreshHistory, refreshRegulationSessions, refreshWatchData]);
useEffect(() => {
if (measurement.snapshot.value === 'cancelled') {
measurement.reset();
}
}, [measurement, measurement.snapshot.value]);
useEffect(() => {
const result = measurement.snapshot.context.result;
if (
measurement.snapshot.value !== 'completed' ||
result === null ||
finalizingResultRef.current ||
finalizationState !== 'idle' ||
currentMeasurement !== null
) {
return;
}
finalizingResultRef.current = true;
setFinalizationState('saving');
(async (): Promise<void> => {
let healthContext: HealthKitContext | null = null;
let contextConsistency = 0.5;
if (
measurement.snapshot.context.healthPermission === 'requestCompleted'
) {
try {
healthContext = await healthKitService.fetchContext(14);
if (healthContext !== null) {
const evaluation = evaluateHealthKitContext(healthContext);
if (evaluation.hasUsableContext) {
contextConsistency = evaluation.contextConfidence;
}
}
} catch {
healthContext = null;
}
}
const baselineResult = await baselineRepository.calculate({
measurementHourLocal: new Date().getHours(),
cameraPosition,
});
const potentialStress = calculatePotentialStress({
atRest: measurementAtRestRef.current,
bpm: result.bpm.bpm,
estimatedRmssdMs: result.prv.estimatedRmssdMs,
prvReliable: result.prv.reliable,
validIntervalCount: result.prv.validIntervalCount,
signalQuality: result.quality.score,
usableDurationSeconds: result.quality.usableDurationSeconds,
peakConfidence: result.bpm.confidence,
methodAgreementScore: result.quality.methodAgreementScore,
contextConsistency,
baseline: baselineResult.baseline,
});
const record: StoredMeasurement = {
schemaVersion: 1,
algorithmVersion: PPG_ALGORITHM_VERSION,
id: createMeasurementId(),
acquisition: {
cameraPosition,
torchUsed: cameraPosition === 'back',
},
measuredAt: new Date().toISOString(),
atRest: measurementAtRestRef.current,
baselineEligible:
measurementAtRestRef.current && !postRegulationMeasurementRef.current,
cameraResult: result,
potentialStress,
healthKitContext: healthContext,
selfReport: null,
};
await measurementRepository.add(record);
if (
pendingRegulationSession !== null &&
regulationBeforeMeasurement !== null
) {
try {
await regulationSessionRepository.complete(
pendingRegulationSession.id,
record.id,
record.measuredAt,
);
if (pendingCoherenceSessionId !== null) {
await coherenceSessionRepository.attachAfterMeasurement(
pendingCoherenceSessionId,
record.id,
);
}
await refreshRegulationSessions();
} catch {
// The physiological result remains usable even if the local journey
// link cannot be persisted. Never discard a completed measurement.
}
setRegulationComparison(
compareRegulationMeasurements(regulationBeforeMeasurement, record),
);
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
}
await refreshBaseline();
setCurrentMeasurement(record);
if (pendingProtocolIntent !== null && pendingRegulationSession === null) {
setExerciseProtocol(pendingProtocolIntent);
setRegulationBeforeMeasurement(record);
setPendingProtocolIntent(null);
setAppView('hapticExercise');
}
setSelfReportState('idle');
refreshHistory().catch(() => undefined);
})()
.catch(() => {
setFinalizationState('failed');
})
.finally(() => {
finalizingResultRef.current = false;
});
}, [
currentMeasurement,
cameraPosition,
finalizationState,
measurement.snapshot.context.healthPermission,
measurement.snapshot.context.result,
measurement.snapshot.value,
pendingRegulationSession,
pendingCoherenceSessionId,
pendingProtocolIntent,
regulationBeforeMeasurement,
refreshHistory,
refreshBaseline,
refreshRegulationSessions,
]);
const handleHealthKitPreference = useCallback((value: boolean): void => {
setIncludeHealthKit(value);
settingsRepository.setIncludeHealthKitContext(value);
}, []);
const handleSaveMindAndBodyPreference = useCallback((value: boolean): void => {
setSaveMindAndBodyWorkouts(value);
settingsRepository.setSaveMindAndBodyWorkouts(value).catch(() => undefined);
watchSyncCoordinator.updatePreferences({
schemaVersion: 1,
saveMindAndBodyWorkouts: value,
protocolCatalogVersion: '1',
updatedAt: new Date().toISOString(),
});
}, []);
const handleOnboardingHealthKit = useCallback(async (): Promise<boolean> => {
if (!healthKitService.isAvailable()) {
return false;
}
try {
const accepted = await healthKitService.requestReadAuthorization();
if (accepted) {
setIncludeHealthKit(true);
await settingsRepository.setIncludeHealthKitContext(true);
}
return accepted;
} catch {
return false;
}
}, []);
const handleCameraPositionPreference = useCallback(
(value: 'back' | 'front'): void => {
setCameraPosition(value);
acquisitionPreferencesRepository
.set({ cameraPosition: value, sensitiveContact })
.catch(() => undefined);
},
[sensitiveContact],
);
const handleSensitiveContactPreference = useCallback(
(value: boolean): void => {
setSensitiveContact(value);
acquisitionPreferencesRepository
.set({ cameraPosition, sensitiveContact: value })
.catch(() => undefined);
},
[cameraPosition],
);
const startMeasurement = useCallback((atRestOverride?: boolean): void => {
const selectedAtRest = atRestOverride ?? atRestSelection;
if (selectedAtRest === null) return;
setAppView('measurement');
setCurrentMeasurement(null);
setSelfReportState('idle');
setFinalizationState('idle');
finalizingResultRef.current = false;
measurementAtRestRef.current = selectedAtRest;
postRegulationMeasurementRef.current = pendingRegulationSession !== null;
if (pendingRegulationSession === null) {
setRegulationComparison(null);
setRegulationBeforeMeasurement(null);
}
measurement.start({
includeHealthKitContext: includeHealthKit,
sensitiveContact,
requestCameraPermission: cameraPermission.requestCameraPermission,
});
}, [
cameraPermission.requestCameraPermission,
atRestSelection,
includeHealthKit,
measurement,
pendingRegulationSession,
sensitiveContact,
]);
const restartMeasurement = useCallback((): void => {
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(null);
setFinalizationState('idle');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = false;
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setRegulationBeforeMeasurement(null);
setRegulationComparison(null);
}, [measurement]);
const retryMeasurement = useCallback((): void => {
setCurrentMeasurement(null);
finalizingResultRef.current = false;
measurement.retry();
if (measurement.snapshot.context.cameraPermission !== 'granted') {
Promise.resolve().then(() => startMeasurement());
}
}, [measurement, startMeasurement]);
const backToPreparation = useCallback((): void => {
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(null);
setFinalizationState('idle');
setSelfReportState('idle');
setAppView('measurement');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = false;
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setRegulationBeforeMeasurement(null);
setRegulationComparison(null);
}, [measurement]);
const startRegulationJourney = useCallback(
(protocolKind: HapticRegulationProtocolKind): void => {
if (currentMeasurement === null) return;
setExerciseProtocol(protocolKind);
setRegulationBeforeMeasurement(currentMeasurement);
setRegulationComparison(null);
setAppView('hapticExercise');
},
[currentMeasurement],
);
const completeRegulationExercise = useCallback(
(summary: HapticExerciseSummary, coherenceSessionId?: string): void => {
if (regulationBeforeMeasurement === null || !summary.completed) return;
(async (): Promise<void> => {
const created = await regulationSessionRepository.create({
protocolKind: summary.protocolKind,
beforeMeasurementId: regulationBeforeMeasurement.id,
startedAt: summary.startedAt,
});
if (coherenceSessionId !== undefined) {
await coherenceSessionRepository.attachRegulationSession(
coherenceSessionId,
created.id,
);
setPendingCoherenceSessionId(coherenceSessionId);
} else {
setPendingCoherenceSessionId(null);
}
const awaitingAfter =
await regulationSessionRepository.markAwaitingAfter(
created.id,
summary.completedAt,
);
await refreshRegulationSessions();
setPendingRegulationSession(awaitingAfter);
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(true);
setFinalizationState('idle');
setSelfReportState('idle');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = true;
setAppView('measurement');
})().catch(() => {
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setAppView('measurement');
});
},
[measurement, refreshRegulationSessions, regulationBeforeMeasurement],
);
const saveSelfReport = useCallback(
(report: MeasurementSelfReport): void => {
if (currentMeasurement === null) return;
setSelfReportState('saving');
measurementRepository
.attachSelfReport(currentMeasurement.id, report)
.then(async () => {
const baselineEligible =
currentMeasurement.atRest &&
regulationComparison === null &&
![
report.recentExercise,
report.recentCaffeine,
report.recentMeal,
report.movementDuringMeasurement,
report.phoneUnusuallyWarm,
].some(value => value === true);
setCurrentMeasurement({
...currentMeasurement,
selfReport: report,
baselineEligible,
});
setSelfReportState('saved');
await Promise.all([refreshHistory(), refreshBaseline()]);
})
.catch(() => setSelfReportState('failed'));
},
[currentMeasurement, refreshBaseline, refreshHistory, regulationComparison],
);
const deleteMeasurement = useCallback(
async (measurementId: string): Promise<void> => {
await Promise.all([
measurementRepository.deleteById(measurementId),
regulationSessionRepository.deleteRelatedToMeasurement(measurementId),
coherenceSessionRepository.deleteRelatedToMeasurement(measurementId),
]);
await Promise.all([
refreshHistory(),
refreshBaseline(),
refreshRegulationSessions(),
]);
},
[refreshBaseline, refreshHistory, refreshRegulationSessions],
);
const transferWatchTombstone = useCallback((tombstone: WatchTombstoneV1): void => {
const envelope: WatchSyncEnvelopeV1<WatchTombstoneV1> = {
schemaVersion: 1,
eventId: `iphone-delete-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
deviceId: 'iphone',
sequence: 0,
sentAt: new Date().toISOString(),
kind: 'tombstone',
payload: tombstone,
};
watchSyncCoordinator.transferEvent(envelope);
}, []);
const deleteWatchMeasurement = useCallback(async (measurementId: string): Promise<void> => {
const tombstone = await watchRepository.deleteMeasurement(measurementId);
transferWatchTombstone(tombstone);
await refreshWatchData();
}, [refreshWatchData, transferWatchTombstone]);
const clearMeasurementData = useCallback(async (): Promise<void> => {
const [, , , watchTombstones] = await Promise.all([
measurementRepository.clearAll(),
regulationSessionRepository.clearAll(),
coherenceSessionRepository.clearAll(),
watchRepository.clearAll(),
]);
watchTombstones.forEach(transferWatchTombstone);
setCurrentMeasurement(null);
await Promise.all([
refreshHistory(),
refreshBaseline(),
refreshRegulationSessions(),
refreshWatchData(),
]);
}, [refreshBaseline, refreshHistory, refreshRegulationSessions, refreshWatchData, transferWatchTombstone]);
const changeRetentionPolicy = useCallback(
async (policy: DataRetentionPolicy): Promise<void> => {
await measurementRepository.setRetentionPolicy(policy);
setRetentionPolicy(policy);
await Promise.all([refreshHistory(), refreshBaseline()]);
},
[refreshBaseline, refreshHistory],
);
if (onboardingStatus === 'loading') {
return ( return (
<View style={styles.container}> <View style={styles.loadingResult}>
<NewAppScreen <ActivityIndicator size="large" color={colors.accent} />
templateFileName="App.tsx"
safeAreaInsets={safeAreaInsets}
/>
</View> </View>
); );
} }
if (onboardingStatus === 'required') {
return (
<OnboardingScreen
initialAnswers={onboardingAnswers}
onRequestHealthKit={handleOnboardingHealthKit}
onComplete={answers => {
setOnboardingAnswers(answers);
setOnboardingStatus('completed');
}}
/>
);
}
if (appView === 'history') {
return (
<PPGHistoryScreen
measurements={history}
watchMeasurements={watchHistory}
baselineProgress={baselineProgress}
watchBaseline={watchBaseline}
regulationSessions={regulationSessions}
watchRegulationSessions={watchRegulationSessions}
watchConnectionState={watchConnection}
saveMindAndBodyWorkouts={saveMindAndBodyWorkouts}
retentionPolicy={retentionPolicy}
onDeleteMeasurement={deleteMeasurement}
onDeleteWatchMeasurement={deleteWatchMeasurement}
onClearAll={clearMeasurementData}
onSaveMindAndBodyWorkoutsChange={handleSaveMindAndBodyPreference}
onRetentionPolicyChange={changeRetentionPolicy}
onBack={() => {
setRegulationBeforeMeasurement(null);
setPendingRegulationSession(null);
setAppView('measurement');
}}
/>
);
}
if (appView === 'cardiacMirror') {
return <CardiacMirrorScreen onBack={() => setAppView('measurement')} />;
}
if (appView === 'hapticExercise') {
return (
<HapticWaveExerciseScreen
initialProtocolKind={exerciseProtocol}
onBack={() => setAppView('measurement')}
onCompleted={completeRegulationExercise}
onRequestStart={protocol => {
if (currentMeasurement === null) {
setPendingProtocolIntent(protocol.kind);
setExerciseProtocol(protocol.kind);
setAtRestSelection(true);
setAppView('measurement');
return true;
}
setExerciseProtocol(protocol.kind);
setRegulationBeforeMeasurement(currentMeasurement);
if (protocol.mode === 'livePPG') {
cameraPermission.requestCameraPermission().then(granted => {
if (granted) setAppView('myCoherence');
});
return true;
}
return false;
}}
onCancelled={() => {
setRegulationBeforeMeasurement(null);
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
}}
/>
);
}
if (appView === 'myCoherence') {
return (
<MyCoherenceScreen
cameraPosition={cameraPosition}
beforeMeasurementId={regulationBeforeMeasurement?.id ?? null}
onCompleted={(summary, session) => {
completeRegulationExercise(
{
protocolKind: 'myCoherence',
startedAt: summary.startedAt,
completedAt: summary.completedAt,
elapsedSeconds: summary.elapsedSeconds,
completed: summary.completed,
},
session.id,
);
}}
onCancelled={() => {
setRegulationBeforeMeasurement(null);
setAppView('hapticExercise');
}}
/>
);
}
const state = measurement.snapshot.value;
if (
state === 'preparingCamera' ||
state === 'waitingForFinger' ||
state === 'stabilizing' ||
state === 'measuring' ||
state === 'processing'
) {
return (
<PPGMeasurementScreen
state={state}
latestFrame={measurement.latestFrame}
phaseProgress={measurement.phaseProgress}
isCameraActive={measurement.isCameraActive}
torchEnabled={measurement.torchEnabled}
cameraPosition={cameraPosition}
onCameraStarted={measurement.onCameraStarted}
onFrameUpdate={measurement.onFrameUpdate}
onInterrupted={measurement.onCameraInterrupted}
onCameraError={measurement.onCameraError}
onCancel={measurement.cancel}
/>
);
}
if (state === 'completed') {
if (currentMeasurement === null) {
if (finalizationState === 'failed') {
return (
<PPGFailureScreen
state="error"
message="Le résultat n’a pas pu être enregistré localement. Vous pouvez réessayer sans relancer la caméra."
onRetry={() => setFinalizationState('idle')}
onBack={backToPreparation}
/>
);
}
return (
<View style={styles.loadingResult}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
return (
<PPGResultScreen
cameraResult={currentMeasurement.cameraResult}
measurementHistory={history}
potentialStress={currentMeasurement.potentialStress}
healthKitContext={currentMeasurement.healthKitContext}
baselineProgress={baselineProgress}
regulationComparison={regulationComparison}
recommendedProtocolKind={recommendRegulationProtocol(
currentMeasurement.potentialStress.index,
)}
selfReportSaved={selfReportState === 'saved'}
selfReportSaving={selfReportState === 'saving'}
selfReportError={selfReportState === 'failed'}
onSaveSelfReport={saveSelfReport}
onStartRegulation={startRegulationJourney}
onRestart={restartMeasurement}
onDone={backToPreparation}
/>
);
}
if (
state === 'invalid' ||
state === 'thermalStop' ||
state === 'interrupted' ||
state === 'error'
) {
return (
<PPGFailureScreen
state={state}
message={
measurement.snapshot.context.failure?.message ??
'Le signal n’a pas permis de produire un résultat fiable.'
}
result={measurement.snapshot.context.result}
showOpenSettings={
measurement.snapshot.context.failure?.code ===
'cameraPermissionDenied'
}
onRetry={retryMeasurement}
onBack={backToPreparation}
/>
);
}
return (
<PPGPreparationScreen
includeHealthKit={includeHealthKit}
baselineProgress={baselineProgress}
postExerciseProtocolTitle={
pendingRegulationSession === null
? null
: getRegulationProtocol(pendingRegulationSession.protocolKind).title
}
onIncludeHealthKitChange={handleHealthKitPreference}
sensitiveContact={sensitiveContact}
onSensitiveContactChange={handleSensitiveContactPreference}
cameraPosition={cameraPosition}
onCameraPositionChange={handleCameraPositionPreference}
atRest={atRestSelection}
onAtRestChange={setAtRestSelection}
onStart={startMeasurement}
onOpenHistory={() => setAppView('history')}
onOpenCardiacMirror={() => setAppView('cardiacMirror')}
onOpenHapticExercise={() => setAppView('hapticExercise')}
isStarting={
state === 'requestingCameraPermission' ||
state === 'requestingHealthPermission'
}
/>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { safeArea: {
flex: 1, flex: 1,
backgroundColor: colors.background,
},
loadingResult: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
}, },
}); });
Binary file not shown.
+240 -68
View File
@@ -1,97 +1,269 @@
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). # Haptika — PPG caméra iPhone
# Getting Started Prototype React Native iPhone-only qui réalise une mesure PPG de contact avec la caméra arrière et le flash, estime un BPM et une PRV expérimentale lorsque le signal le permet, construit une baseline personnelle, puis affiche séparément un indice d’activation potentielle et sa confiance.
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. L’application fonctionne hors ligne. Aucune frame, image ou vidéo n’est enregistrée ni envoyée. HealthKit est facultatif, en lecture seule, et ne remplace jamais la mesure caméra actuelle.
## Step 1: Start Metro ## Faisabilité vérifiée
First, you will need to run **Metro**, the JavaScript build tool for React Native. Le prototype est réalisable sur iPhone physique avec des limites importantes :
To start the Metro dev server, run the following command from the root of your React Native project: - VisionCamera 5 expose les frames iOS à un plugin Nitro Swift sans faire traverser les pixels à JavaScript.
- Les timestamps proviennent du `CMSampleBuffer` et ne sont pas supposés réguliers.
- Le format YUV bi-planar est traité directement à faible résolution, avec un chemin BGRA de secours dans l’extracteur.
- HealthKit expose bien `heartRate`, `restingHeartRate`, `heartRateVariabilitySDNN` et les séries de battements existantes.
- Une app iPhone ne peut pas demander à une Apple Watch ou à HealthKit de produire une nouvelle série beat-to-beat. Cette donnée reste donc historique et facultative.
- Une caméra ne mesure pas la pression physique du doigt. Le message « relâchez légèrement » repose uniquement sur une heuristique de saturation et d’aplatissement du signal.
- La mesure caméra fournit une PRV expérimentale, pas une HRV ECG clinique.
```sh Références officielles principales :
# Using npm
npm start
# OR using Yarn - [React Native 0.86](https://reactnative.dev/blog/2026/06/11/react-native-0.86)
yarn start - [VisionCamera — Native Frame Processor Plugins](https://visioncamera.margelo.com/docs/native-frame-processor-plugins)
- [VisionCamera — Frame Output](https://visioncamera.margelo.com/docs/frame-output)
- [React Native Worklets — installation](https://docs.swmansion.com/react-native-worklets/docs/fundamentals/getting-started/)
- [Apple — HKHeartbeatSeriesQuery](https://developer.apple.com/documentation/healthkit/hkheartbeatseriesquery)
- [Apple — HRV SDNN HealthKit](https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/heartratevariabilitysdnn)
- [Apple — état thermique](https://developer.apple.com/documentation/foundation/processinfo/thermalstate-swift.property)
- [Apple — privacy manifests](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files)
## Versions verrouillées
| Élément | Version |
|---|---:|
| React Native | 0.86.0 |
| React | 19.2.3 |
| VisionCamera | 5.1.0 |
| VisionCamera Worklets | 5.1.0 |
| React Native Worklets | 0.10.2 |
| Nitro Modules / Nitrogen | 0.36.1 |
| Nitro Image | 0.15.1 |
| TypeScript | ^5.8.3, strict |
| iOS minimum | 16.0 |
| Node.js | >= 22.11 |
VisionCamera V5 n’utilise plus l’ancien `useFrameProcessor`, `FrameProcessorPluginRegistry` ou `react-native-worklets-core`. Le code utilise `useFrameOutput`, `react-native-worklets` et un HybridObject Nitro typé.
## Architecture
```text
React Native / TypeScript
UI, permissions, machine à états, baseline, indice, confiance,
auto-report, historique et orchestration
│
│ métriques compactes à ~6 Hz
▼
VisionCamera Worklet + Nitro Swift
CVPixelBuffer → ROI → statistiques couleur → contact → buffer numérique
│
│ fin de mesure
▼
Signal Swift déterministe
timestamps → gaps → resampling → detrend/MAD → FIR → canaux
→ pics/IPI → BPM temporel + autocorrélation → PRV → qualité
HealthKit Swift (séparé)
historique sourcé/daté, lecture seule, poids du score principal = 0
``` ```
## Step 2: Build and run your app ### Couche React Native
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: - `src/features/ppg/camera/` : caméra VisionCamera, worklet et arrêt fail-safe du flash.
- `src/features/ppg/measurement/` : machine à états, configuration et coordination native.
- `src/features/ppg/stress/` : baseline robuste, indice potentiel, confiance et interprétation.
- `src/features/ppg/health/` : modèle HealthKit et évaluation de récence.
- `src/features/ppg/storage/` : mesures numériques, réglages et auto-report locaux.
- `src/features/ppg/screens/` et `components/` : préparation, mesure, résultat, historique et composants d’état.
- `src/features/ppg/tests/` : tests Jest des transitions et calculs métier.
### Android ### Couche Swift / Nitro
```sh Le package local `packages/react-native-haptika-ppg` contient :
# Using npm
npm run android
# OR using Yarn - la spec TypeScript Nitrogen `src/specs/HaptikaPPG.nitro.ts` ;
yarn android - les bindings générés dans `nitrogen/generated/` ;
- `PPGFrameMetricsExtractor.swift` pour YUV/BGRA et ROI ;
- `PPGFingerContactDetector.swift` pour le contact multi-caractéristiques avec hystérésis ;
- `HybridHaptikaPPGFrameProcessor.swift` pour le cycle de mesure et le buffer numérique ;
- `ios/Signal/` pour le pipeline final ;
- `PPGThermalMonitor.swift` pour `ProcessInfo.thermalState` ;
- `HybridHaptikaHealthKit.swift` pour HealthKit en lecture seule.
Les bindings Nitro générés remplacent un bridge Objective-C manuel. C’est l’architecture officielle requise par VisionCamera 5 pour un plugin de frame natif.
## Flux d’une frame
1. VisionCamera négocie 30 FPS et une sortie cible 640 × 480.
2. Le worklet appelle le HybridObject Swift et libère toujours la frame dans `finally`.
3. Swift récupère temporairement le `CMSampleBuffer` et son `CVPixelBuffer`.
4. Le buffer est verrouillé en lecture seule et systématiquement déverrouillé avec `defer`.
5. Une grille centrale est échantillonnée directement dans les plans Y et UV. Le chemin BGRA est également supporté.
6. Seuls les moyennes/variances couleur, la luminance, la saturation, le contact et le mouvement sont conservés.
7. Le `CMSampleBuffer` et le `CVPixelBuffer` ne sont jamais retenus après le callback.
8. Une mise à jour compacte est envoyée à React Native tous les cinq frames, soit environ 6 Hz.
## Pipeline du signal
- validation des valeurs finies et des timestamps monotones ;
- diagnostic FPS, dispersion, frames manquantes et lacunes longues ;
- interpolation uniquement des petites lacunes ;
- masque invalide autour des interruptions ;
- rééchantillonnage régulier à 30 Hz ;
- suppression de tendance et normalisation médiane/MAD ;
- filtre FIR passe-bande recalculé pour le sample rate réel ;
- comparaison rouge, vert, luminance et blend normalisé ;
- détection adaptative des pics dans les deux polarités ;
- raffinement temporel et rejet des doubles pics/intervalles aberrants ;
- BPM temporel par médiane des IPI ;
- estimation indépendante par autocorrélation ;
- publication du BPM seulement si les méthodes et la qualité concordent ;
- RMSSD/SDNN des IPI uniquement comme PRV expérimentale ;
- score de qualité global et causes d’invalidation explicites.
Le FIR Swift porte sur environ quatre canaux × 1 800 échantillons. Cette charge est faible et déterministe ; Accelerate/vDSP n’a pas été ajouté sans bénéfice mesuré. La convolution est isolée et pourra être remplacée par vDSP après profilage sur les modèles d’iPhone ciblés.
## Baseline, indice et confiance
- seules les mesures caméra valides, au repos et de qualité ≥ 0,65 alimentent la baseline ;
- le contexte « au repos » est confirmé avant chaque capture ; une mesure hors repos peut afficher le BPM et la qualité, mais aucun indice comparatif ;
- médiane et MAD limitent l’effet des valeurs extrêmes ;
- avant cinq mesures comparables, aucun indice personnalisé n’est affiché ;
- de cinq à neuf mesures antérieures, le résultat est provisoire et sa confiance plafonnée ;
- à partir de dix mesures, le statut peut devenir personnalisé ;
- le BPM et le log-RMSSD caméra sont normalisés séparément ;
- si la PRV est indisponible, l’indice utilise uniquement le BPM et la confiance est fortement réduite ;
- `healthKitWeight` est validé comme strictement égal à zéro ;
- HealthKit peut seulement contribuer à la cohérence contextuelle de la confiance.
## HealthKit
L’autorisation est demandée séparément et n’empêche jamais la caméra de fonctionner. Le module demande uniquement la lecture de :
- fréquence cardiaque ;
- fréquence cardiaque au repos ;
- HRV SDNN ;
- série de battements existante.
Chaque valeur conserve son unité, ses dates, son application source, son bundle source et son appareil lorsqu’il est disponible. Apple ne permet pas de distinguer un refus de lecture d’un historique vide : l’app mémorise donc seulement que la demande s’est terminée, jamais un faux statut « autorisé ». Une valeur ancienne est marquée comme contexte obsolète et n’est jamais affichée comme une valeur actuelle. SDNN HealthKit, PRV caméra, BPM caméra et fréquence au repos restent des métriques distinctes.
Pour la minimisation, chaque mesure persistée conserve au plus la dernière fréquence cardiaque historique, la dernière fréquence au repos et le dernier SDNN accessibles. La confiance contextuelle est recalculée à partir de ce même sous-ensemble ; les 100 échantillons que la requête native peut consulter transitoirement ne sont pas dupliqués dans l’historique local.
## Installation
## Module 7 — Miroir cardiaque
Le miroir cardiaque est une expérience différentielle isolée du pipeline PPG.
Son pattern haptique propriétaire est identifié dans le code par
`HAPTIKA.CARDIAC_MIRROR_BEAT` et versionné `1.0.0`. Aucune promesse de latence
par rapport à un battement biologique n’est faite : les API Workout publiques
fournissent des mises à jour de fréquence, pas des événements battement par battement.
Deux modes sont visibles dans l’interface :
- **Connecté · Watch+** : la Watch mesure sans vibration et transmet ses mises à
jour de fréquence. L’iPhone reconstruit le rythme visuel et Core Haptics.
- **Estimation · iPhone** : utilise une fréquence de départ saisie par
l’utilisateur et applique une décélération dynamique de 1 à 2 BPM/minute.
Ce mode est explicitement dégradé et ne mesure pas chaque battement.
La cible SwiftUI watchOS 11 fonctionne aussi hors connexion, puis synchronise
ses résumés et séances avec l’iPhone appairé via WatchConnectivity.
Une bibliothèque de régulation est également disponible sans Watch ni caméra.
Elle reprend les quatre protocoles du Sprint 0 avec une interface bleu nuit et
des ondes visuelles synchronisées :
- Cohérence 5–5, pendant 5 minutes ;
- Expiration 4–6, pendant 3 minutes ;
- SOS tension, pendant environ 1 minute ;
- Micro-ancrage, pendant 37 secondes.
Les patterns sont produits par Core Haptics et non par une vibration JavaScript
générique. Ces exercices sont des guidages sensoriels, pas des mesures ni des
protocoles médicaux de traitement du stress.
Prérequis : macOS, Xcode 26 ou version compatible RN 0.86, Node 22+, CocoaPods 1.16+, compte Apple Developer pour HealthKit et un iPhone physique.
```bash
cd HaptikaApp
npm ci
npm run native:specs
cd ios
pod install
cd ..
open ios/HaptikaApp.xcworkspace
``` ```
### iOS Dans Xcode :
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). 1. sélectionner la cible `HaptikaApp` ;
2. choisir l’équipe de signature ;
3. vérifier le bundle ID `com.haptika.iphone` ;
4. vérifier la capability HealthKit et `HaptikaApp.entitlements` ;
5. choisir un iPhone physique ;
6. lancer l’application.
The first time you create a new project, run the Ruby bundler to install CocoaPods itself: Le projet inclut déjà `NSCameraUsageDescription`, `NSHealthShareUsageDescription`, l’entitlement HealthKit, la protection de fichiers complète et `PrivacyInfo.xcprivacy`.
```sh ## Tests
bundle install
```bash
cd HaptikaApp
npm run typecheck
npm run lint
npm run test:ci
cd packages/react-native-haptika-ppg
swift test
cd ../../../SignalProcessingKit
swift test
swift test -c release
``` ```
Then, and every time you update your native dependencies, run: État de validation du prototype : 42 tests Jest, 3 tests Swift CVPixelBuffer et 17 tests Swift du pipeline signal passent. Le workspace iOS compile pour iPhone Simulator ; la calibration PPG et le cycle réel du flash restent à valider sur appareil physique.
```sh Le simulateur valide l’UI et le build, mais pas le contact PPG, le comportement réel du flash, la chauffe, les interruptions matérielles ou les données HealthKit de production.
bundle exec pod install
```
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). ### Procédure iPhone physique
```sh 1. retirer une coque qui masque la caméra ou le flash ;
# Using npm 2. autoriser la caméra ;
npm run ios 3. laisser HealthKit désactivé pour le premier test ;
4. poser doucement le doigt sur la caméra arrière et le flash ;
5. rester immobile et respirer normalement pendant la stabilisation puis l’acquisition ;
6. vérifier l’arrêt immédiat du flash après succès, erreur, annulation et mise en arrière-plan ;
7. répéter à 60, 75 et 120 BPM avec un simulateur optique ou une référence externe si disponible ;
8. tester plusieurs modèles d’iPhone et niveaux de batterie/température ;
9. répéter au moins cinq à dix mesures au repos pour évaluer la stabilité personnelle ;
10. comparer les résultats à une référence validée avant toute affirmation de précision.
# OR using Yarn ## Confidentialité
yarn ios
```
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. - [x] aucune frame enregistrée ou transférée à JavaScript ;
- [x] aucune photo/vidéo écrite dans le conteneur ou la photothèque ;
- [x] aucune dépendance réseau fonctionnelle ;
- [x] métriques numériques et auto-report stockés localement ;
- [x] protection par défaut `NSFileProtectionComplete` ;
- [x] HealthKit en lecture seule et facultatif ;
- [x] contexte HealthKit minimal, daté et sourcé (au plus trois métriques par mesure) ;
- [x] manifeste de confidentialité sans tracking ni collecte distante ;
- [ ] audit d’archive Xcode à refaire pour chaque mise à jour de dépendance ;
- [ ] politique de suppression/export des données à finaliser avant production.
This is one way to run your app — you can also build it directly from Android Studio or Xcode. ## Checklist App Store
## Step 3: Modify your app - [ ] tester et calibrer sur les iPhone réellement supportés ;
- [ ] fournir une méthodologie de validation et des limites de précision ;
- [ ] conserver partout le vocabulaire expérimental/non médical ;
- [ ] ne jamais afficher « stress détecté », « diagnostic » ou « crise » ;
- [ ] vérifier les captures App Store et textes de permission ;
- [ ] vérifier le rapport de confidentialité de l’archive ;
- [ ] remplir les réponses App Privacy conformément au traitement strictement local ;
- [ ] vérifier que HealthKit n’est pas utilisé pour publicité, profilage ou revente ;
- [ ] confirmer l’arrêt de la torche dans tous les parcours d’interruption ;
- [ ] effectuer une revue accessibilité et localisation finale.
Now that you have successfully run the app, let's make changes! ## Limites scientifiques et techniques
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). Ce prototype ne constitue ni un dispositif médical, ni un ECG, ni une mesure clinique du stress ou du système nerveux autonome. Les artefacts de mouvement, la pigmentation, la position du doigt, la géométrie caméra/flash, l’exposition automatique, la température et le modèle d’iPhone influencent la mesure. La PRV caméra ne doit pas être assimilée à une HRV ECG. Les écarts isolés sont insuffisants : l’interprétation repose sur des mesures répétées, une baseline personnelle, le contexte et un auto-report séparé.
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
## Congratulations! :tada:
You've successfully run and modified your React Native App. :partying_face:
### Now what?
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
# Troubleshooting
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
# Learn More
To learn more about React Native, take a look at the following resources:
- [React Native Website](https://reactnative.dev) - learn more about React Native.
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.
+39
View File
@@ -4,6 +4,45 @@
import React from 'react'; import React from 'react';
import ReactTestRenderer from 'react-test-renderer'; import ReactTestRenderer from 'react-test-renderer';
jest.mock('react-native-vision-camera', () => ({
Camera: () => null,
useCameraDevice: () => undefined,
useCameraPermission: () => ({
hasPermission: true,
requestPermission: jest.fn(async () => true),
}),
useFrameOutput: () => ({}),
}));
jest.mock('react-native-worklets', () => ({
scheduleOnRN: jest.fn(),
}));
jest.mock('react-native-haptika-ppg', () => ({
haptikaPPG: {
startMeasurement: jest.fn(),
processFrame: jest.fn(),
finishMeasurement: jest.fn(),
cancelMeasurement: jest.fn(),
getThermalState: jest.fn(() => 'nominal'),
},
haptikaHealthKit: {
isAvailable: jest.fn(() => false),
requestReadAuthorization: jest.fn(async () => false),
fetchContext: jest.fn(),
fetchLatestHeartbeatSeries: jest.fn(),
},
}));
jest.mock('@react-native-async-storage/async-storage', () => ({
__esModule: true,
default: {
getItem: jest.fn(async () => null),
setItem: jest.fn(async () => undefined),
},
}));
import App from '../App'; import App from '../App';
test('renders correctly', async () => { test('renders correctly', async () => {
+1
View File
@@ -1,3 +1,4 @@
module.exports = { module.exports = {
presets: ['module:@react-native/babel-preset'], presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin'],
}; };
+45
View File
@@ -0,0 +1,45 @@
# Design QA — écrans Figma 123
## Références
- Figma 123:527 — accueil de mesure
- Figma 123:465 — préparation
- Figma 123:404 — mesure en cours
- Figma 123:320 — résultat
- Comparaison combinée : `qa/figma-123/comparison-all.jpg`
## Environnement de contrôle
- Application : React Native 0.86, build Debug iOS
- Simulateur : iPhone 17 Pro Max, iOS 26.5
- Capture optimisée : 368 × 800 px
- Les états mesure et résultat ont été contrôlés avec des données de prévisualisation réalistes, retirées après la capture.
- La marge supérieure supplémentaire de l’application correspond à la zone sûre réelle de la Dynamic Island, absente des cadres Figma.
## Comparaison visuelle
| Écran | Contrôles | Résultat |
| --- | --- | --- |
| Accueil | hiérarchie, axe, silhouette, carte caméra, CTA, navigation | conforme |
| Préparation | en-tête, photographie source, quatre consignes, alerte, CTA | conforme ; contenu défilable sur les hauteurs réduites |
| Mesure | en-tête, statut direct, halo, signal, arrêt, progression | conforme |
| Résultat | score, axe, métriques, recommandation, actions | conforme et alimenté par les vraies données |
## Contrôles fonctionnels
- [x] Les CTA accueil → préparation → mesure sont actifs.
- [x] Arrêter et Annuler interrompent la mesure.
- [x] Lancer la séance et Terminer conservent leurs callbacks produit.
- [x] Le menu, les réglages et la navigation basse restent actifs.
- [x] Les images sont les assets exportés du fichier Figma.
- [x] Le rendu respecte la zone sûre des iPhone avec Dynamic Island.
- [x] Aucun mode de démonstration n’est conservé dans le code.
## Validation technique
- [x] `npm run typecheck`
- [x] `npm run lint`
- [x] `npm run test:ci` — 17 suites, 86 tests
- [x] Build et lancement iOS sur simulateur
Résultat final : **PASS**.
+463 -8
View File
@@ -7,26 +7,120 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
09FC73E86512568662FCE68E /* WatchMeasurementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */; };
0C80B921A6F3F58F76C31292 /* libPods-HaptikaApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */; }; 0C80B921A6F3F58F76C31292 /* libPods-HaptikaApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */; };
114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
140882F922D2F73D8BDA7471 /* WatchResourceArbiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */; };
1497DAF82CAFD24F955F103F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
1BD3026EBCA93A2454A9B571 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */; };
2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */; };
4BCA2B05E3DE6C7AAB303B80 /* WatchAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111670E33699C47D3A93AC41 /* WatchAppModel.swift */; };
4E8C31BD8AC8CA86BADD6118 /* RegulationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B35DC921291116A06F055C /* RegulationProtocol.swift */; };
52352554AE62A5DBE62E1F80 /* WatchCoherenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D79C037057C356A5804C171 /* WatchCoherenceView.swift */; };
57EE4CD6E860841A6FBE3E31 /* WatchHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
77BA4CD21D33B597ECD2FDE7 /* WatchBeforeAfterFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */; };
78F476213C9C09971A280386 /* WatchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D728BA3B4A37EEAC4733EB /* WatchModels.swift */; };
7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D442ED95A5832B6D76BE3836 /* WatchRootView.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */; };
8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E742A36D6693717080FD19DB /* WatchBaseline.swift */; };
93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */; };
A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */; };
A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */; };
B823EAA6372CFAB8566CB32C /* HaptikaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */; };
C0DCCDA0101428B2C431DF72 /* WatchCardiacMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */; };
CAE5DC58530D8CEB55E32C3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E5969515006E0E77675C21 /* Foundation.framework */; };
CC2201B4509C4CF1F751EDF3 /* WatchTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845A00D463125569C206227 /* WatchTheme.swift */; };
D7C2CEDAA071025E0CFBA522 /* WatchMeasurementValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */; };
E0CE73E6D3D989EDA8C0E890 /* WatchHealthMeasurementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */; };
F7A804FDCA84B13EE87ED3FC /* WatchSyncEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */; };
F8117E6E1721ACDD6B88F7C1 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E5969515006E0E77675C21 /* Foundation.framework */; };
FACC8C666D0D4D58E2E18B40 /* Haptika Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
1DC509C0BED42BBA3A0DAE4E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AB289AE8AED259F5623A2E42;
remoteInfo = "Haptika Watch App";
};
2FE1ADCC102A2D599E060B6D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AB289AE8AED259F5623A2E42;
remoteInfo = "Haptika Watch App";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
EFAEDF4DE57326DECDBE233E /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 16;
files = (
FACC8C666D0D4D58E2E18B40 /* Haptika Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0142DCF485AB5826009D8407 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchResourceArbiter.swift; sourceTree = "<group>"; };
111670E33699C47D3A93AC41 /* WatchAppModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchAppModel.swift; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* HaptikaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HaptikaApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* HaptikaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HaptikaApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = HaptikaApp/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = HaptikaApp/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HaptikaApp/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HaptikaApp/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = HaptikaApp/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = HaptikaApp/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HaptikaWatchApp.swift; sourceTree = "<group>"; };
32D728BA3B4A37EEAC4733EB /* WatchModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModels.swift; sourceTree = "<group>"; };
3B4392A12AC88292D35C810B /* Pods-HaptikaApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.debug.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.debug.xcconfig"; sourceTree = "<group>"; }; 3B4392A12AC88292D35C810B /* Pods-HaptikaApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.debug.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.debug.xcconfig"; sourceTree = "<group>"; };
3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementValidator.swift; sourceTree = "<group>"; };
45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCardiacMirrorView.swift; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-HaptikaApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.release.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.release.xcconfig"; sourceTree = "<group>"; }; 5709B34CF0A7D63546082F79 /* Pods-HaptikaApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.release.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HaptikaApp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HaptikaApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementView.swift; sourceTree = "<group>"; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HaptikaApp/AppDelegate.swift; sourceTree = "<group>"; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HaptikaApp/AppDelegate.swift; sourceTree = "<group>"; };
78E5969515006E0E77675C21 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSyncEnvelope.swift; sourceTree = "<group>"; };
816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBeforeAfterFlowView.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HaptikaApp/LaunchScreen.storyboard; sourceTree = "<group>"; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HaptikaApp/LaunchScreen.storyboard; sourceTree = "<group>"; };
8D79C037057C356A5804C171 /* WatchCoherenceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoherenceView.swift; sourceTree = "<group>"; };
9DB449283FD6350D75DDC204 /* Haptika Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Haptika Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "regulation-protocols.v1.json"; sourceTree = "<group>"; };
A845A00D463125569C206227 /* WatchTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchTheme.swift; sourceTree = "<group>"; };
A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HaptikaWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = HaptikaWatch.entitlements; sourceTree = "<group>"; };
CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchConnectivityService.swift; sourceTree = "<group>"; };
CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
D442ED95A5832B6D76BE3836 /* WatchRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = "<group>"; };
D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchExercisesView.swift; sourceTree = "<group>"; };
D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHistoryView.swift; sourceTree = "<group>"; };
D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHealthMeasurementService.swift; sourceTree = "<group>"; };
E6B35DC921291116A06F055C /* RegulationProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RegulationProtocol.swift; sourceTree = "<group>"; };
E742A36D6693717080FD19DB /* WatchBaseline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBaseline.swift; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoreTests.swift; sourceTree = "<group>"; };
FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHapticService.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
0F146085D8726D5484AC45CF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
F8117E6E1721ACDD6B88F7C1 /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F8C1A680F5B00A75B9A /* Frameworks */ = { 13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -35,9 +129,32 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B6BFDAFC89FF560DC3940EE4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CAE5DC58530D8CEB55E32C3E /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
0AF569BB6E060433D5185B95 /* Core */ = {
isa = PBXGroup;
children = (
E6B35DC921291116A06F055C /* RegulationProtocol.swift */,
E742A36D6693717080FD19DB /* WatchBaseline.swift */,
3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */,
32D728BA3B4A37EEAC4733EB /* WatchModels.swift */,
0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */,
CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */,
7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */,
);
name = Core;
path = Core;
sourceTree = "<group>";
};
13B07FAE1A68108700A75B9A /* HaptikaApp */ = { 13B07FAE1A68108700A75B9A /* HaptikaApp */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -55,10 +172,20 @@
children = ( children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */, 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */,
A545FB6B835260DD2FAABBBD /* watchOS */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */ = {
isa = PBXGroup;
children = (
ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */,
);
name = HaptikaWatchTests;
path = HaptikaWatchTests;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = { 832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -74,6 +201,9 @@
83CBBA001A601CBA00E9B192 /* Products */, 83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */, 2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */, BBD78D7AC51CEA395F1C20DB /* Pods */,
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */,
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */,
E900FA2623A3C868FA97EFEE /* HaptikaWatch */,
); );
indentWidth = 2; indentWidth = 2;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -84,10 +214,40 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
13B07F961A680F5B00A75B9A /* HaptikaApp.app */, 13B07F961A680F5B00A75B9A /* HaptikaApp.app */,
9DB449283FD6350D75DDC204 /* Haptika Watch App.app */,
A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */ = {
isa = PBXGroup;
children = (
9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */,
);
name = HaptikaShared;
path = ../shared;
sourceTree = "<group>";
};
A545FB6B835260DD2FAABBBD /* watchOS */ = {
isa = PBXGroup;
children = (
78E5969515006E0E77675C21 /* Foundation.framework */,
);
name = watchOS;
sourceTree = "<group>";
};
B06321B98A4E85D4005C94CB /* Services */ = {
isa = PBXGroup;
children = (
CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */,
FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */,
D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */,
);
name = Services;
path = Services;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = { BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -97,6 +257,47 @@
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E900FA2623A3C868FA97EFEE /* HaptikaWatch */ = {
isa = PBXGroup;
children = (
EB11F95E679D64159FCFDE76 /* App */,
0AF569BB6E060433D5185B95 /* Core */,
B06321B98A4E85D4005C94CB /* Services */,
F1595BA33FD735C10E065549 /* Views */,
AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */,
0142DCF485AB5826009D8407 /* Info.plist */,
BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */,
);
name = HaptikaWatch;
path = HaptikaWatch;
sourceTree = "<group>";
};
EB11F95E679D64159FCFDE76 /* App */ = {
isa = PBXGroup;
children = (
2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */,
111670E33699C47D3A93AC41 /* WatchAppModel.swift */,
);
name = App;
path = App;
sourceTree = "<group>";
};
F1595BA33FD735C10E065549 /* Views */ = {
isa = PBXGroup;
children = (
816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */,
45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */,
8D79C037057C356A5804C171 /* WatchCoherenceView.swift */,
D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */,
D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */,
5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */,
D442ED95A5832B6D76BE3836 /* WatchRootView.swift */,
A845A00D463125569C206227 /* WatchTheme.swift */,
);
name = Views;
path = Views;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -111,16 +312,53 @@
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
EFAEDF4DE57326DECDBE233E /* Embed Watch Content */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
4017CDC02DF1C913443BA87D /* PBXTargetDependency */,
); );
name = HaptikaApp; name = HaptikaApp;
productName = HaptikaApp; productName = HaptikaApp;
productReference = 13B07F961A680F5B00A75B9A /* HaptikaApp.app */; productReference = 13B07F961A680F5B00A75B9A /* HaptikaApp.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
AB289AE8AED259F5623A2E42 /* Haptika Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = C9284034880924E3E8455E96 /* Build configuration list for PBXNativeTarget "Haptika Watch App" */;
buildPhases = (
2803498A232C8DD92F5D6055 /* Sources */,
0F146085D8726D5484AC45CF /* Frameworks */,
8BA43C7D9C0E69C3DFF61044 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = "Haptika Watch App";
productName = "Haptika Watch App";
productReference = 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */;
productType = "com.apple.product-type.application";
};
EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 774FEB5EB351F846855029C1 /* Build configuration list for PBXNativeTarget "HaptikaWatchTests" */;
buildPhases = (
CD9C4519B70F549386BFF6FF /* Sources */,
B6BFDAFC89FF560DC3940EE4 /* Frameworks */,
B8FDE984F342D03CB0CC5583 /* Resources */,
);
buildRules = (
);
dependencies = (
CE1B0BDBE98F17187CFC4F67 /* PBXTargetDependency */,
);
name = HaptikaWatchTests;
productName = HaptikaWatchTests;
productReference = A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -132,6 +370,15 @@
13B07F861A680F5B00A75B9A = { 13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1120; LastSwiftMigration = 1120;
}; };
AB289AE8AED259F5623A2E42 = {
CreatedOnToolsVersion = 26.0;
ProvisioningStyle = Automatic;
};
EA92A2DC9365C1414126C0B1 = {
CreatedOnToolsVersion = 26.0;
ProvisioningStyle = Automatic;
TestTargetID = AB289AE8AED259F5623A2E42;
};
}; };
}; };
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */;
@@ -148,6 +395,8 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
13B07F861A680F5B00A75B9A /* HaptikaApp */, 13B07F861A680F5B00A75B9A /* HaptikaApp */,
AB289AE8AED259F5623A2E42 /* Haptika Watch App */,
EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -159,6 +408,23 @@
files = ( files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
1497DAF82CAFD24F955F103F /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
8BA43C7D9C0E69C3DFF61044 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */,
114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B8FDE984F342D03CB0CC5583 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -189,10 +455,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks.sh\"\n";
@@ -228,10 +498,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources.sh\"\n";
@@ -248,8 +522,58 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
2803498A232C8DD92F5D6055 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B823EAA6372CFAB8566CB32C /* HaptikaWatchApp.swift in Sources */,
4BCA2B05E3DE6C7AAB303B80 /* WatchAppModel.swift in Sources */,
4E8C31BD8AC8CA86BADD6118 /* RegulationProtocol.swift in Sources */,
8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */,
D7C2CEDAA071025E0CFBA522 /* WatchMeasurementValidator.swift in Sources */,
78F476213C9C09971A280386 /* WatchModels.swift in Sources */,
140882F922D2F73D8BDA7471 /* WatchResourceArbiter.swift in Sources */,
1BD3026EBCA93A2454A9B571 /* WatchStore.swift in Sources */,
F7A804FDCA84B13EE87ED3FC /* WatchSyncEnvelope.swift in Sources */,
A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */,
A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */,
E0CE73E6D3D989EDA8C0E890 /* WatchHealthMeasurementService.swift in Sources */,
77BA4CD21D33B597ECD2FDE7 /* WatchBeforeAfterFlowView.swift in Sources */,
C0DCCDA0101428B2C431DF72 /* WatchCardiacMirrorView.swift in Sources */,
52352554AE62A5DBE62E1F80 /* WatchCoherenceView.swift in Sources */,
93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */,
57EE4CD6E860841A6FBE3E31 /* WatchHistoryView.swift in Sources */,
09FC73E86512568662FCE68E /* WatchMeasurementView.swift in Sources */,
7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */,
CC2201B4509C4CF1F751EDF3 /* WatchTheme.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CD9C4519B70F549386BFF6FF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
4017CDC02DF1C913443BA87D /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = "Haptika Watch App";
target = AB289AE8AED259F5623A2E42 /* Haptika Watch App */;
targetProxy = 2FE1ADCC102A2D599E060B6D /* PBXContainerItemProxy */;
};
CE1B0BDBE98F17187CFC4F67 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = "Haptika Watch App";
target = AB289AE8AED259F5623A2E42 /* Haptika Watch App */;
targetProxy = 1DC509C0BED42BBA3A0DAE4E /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = { 13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@@ -257,10 +581,12 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = HaptikaApp/HaptikaApp.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = HaptikaApp/Info.plist; INFOPLIST_FILE = HaptikaApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -271,12 +597,12 @@
"-ObjC", "-ObjC",
"-lc++", "-lc++",
); );
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev;
PRODUCT_NAME = HaptikaApp; PRODUCT_NAME = HaptikaApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Debug; name = Debug;
@@ -287,9 +613,11 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = HaptikaApp/HaptikaApp.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
INFOPLIST_FILE = HaptikaApp/Info.plist; INFOPLIST_FILE = HaptikaApp/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -300,15 +628,33 @@
"-ObjC", "-ObjC",
"-lc++", "-lc++",
); );
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev;
PRODUCT_NAME = HaptikaApp; PRODUCT_NAME = HaptikaApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Release; name = Release;
}; };
736D5E5CBB3D4C53DB7FEBE2 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = R537R56465;
GENERATE_INFOPLIST_FILE = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
SDKROOT = watchos;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Haptika Watch App.app/Haptika Watch App";
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = { 83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -358,7 +704,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift, /usr/lib/swift,
"$(inherited)", "$(inherited)",
@@ -370,6 +716,10 @@
); );
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = ( OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)", "$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG", "-DFOLLY_NO_CONFIG",
@@ -377,8 +727,14 @@
"-DFOLLY_USE_LIBCPP=1", "-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1",
"-DRCT_REMOVE_LEGACY_ARCH=1",
); );
PODFILE_DIR = "$(SRCROOT)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
}; };
name = Debug; name = Debug;
}; };
@@ -424,7 +780,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift, /usr/lib/swift,
"$(inherited)", "$(inherited)",
@@ -435,6 +791,10 @@
"\"$(inherited)\"", "\"$(inherited)\"",
); );
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = ( OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)", "$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG", "-DFOLLY_NO_CONFIG",
@@ -442,12 +802,89 @@
"-DFOLLY_USE_LIBCPP=1", "-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1",
"-DRCT_REMOVE_LEGACY_ARCH=1",
); );
PODFILE_DIR = "$(SRCROOT)";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
}; };
A6B4F5957EED7AFABF739D57 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = HaptikaWatch/HaptikaWatch.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HaptikaWatch/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchkitapp;
PRODUCT_MODULE_NAME = HaptikaWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Release;
};
AA137F90108381911B05309E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = R537R56465;
GENERATE_INFOPLIST_FILE = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests;
SDKROOT = watchos;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Haptika Watch App.app/Haptika Watch App";
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
D11D33BA631ABF82C65B2BFC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = HaptikaWatch/HaptikaWatch.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = R537R56465;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HaptikaWatch/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchkitapp;
PRODUCT_MODULE_NAME = HaptikaWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.0;
};
name = Debug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -460,6 +897,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
774FEB5EB351F846855029C1 /* Build configuration list for PBXNativeTarget "HaptikaWatchTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
736D5E5CBB3D4C53DB7FEBE2 /* Release */,
AA137F90108381911B05309E /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */ = { 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -469,6 +915,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
C9284034880924E3E8455E96 /* Build configuration list for PBXNativeTarget "Haptika Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A6B4F5957EED7AFABF739D57 /* Release */,
D11D33BA631ABF82C65B2BFC /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA92A2DC9365C1414126C0B1"
BuildableName = ".xctest"
BlueprintName = "HaptikaWatchTests"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:HaptikaApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.default-data-protection</key>
<string>NSFileProtectionComplete</string>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+5 -11
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>HaptikaApp</string> <string>Haptika</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -28,14 +28,15 @@
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<false/> <false/>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSCameraUsageDescription</key>
<string></string> <string>Haptika utilise la caméra arrière et le flash pour mesurer localement un signal pulsatile pendant environ 60 secondes. Aucune image n’est enregistrée.</string>
<key>NSHealthShareUsageDescription</key>
<string>Haptika lit, avec votre accord, votre fréquence cardiaque, votre fréquence au repos et votre SDNN comme contexte historique facultatif. Ces données ne remplacent jamais la mesure caméra.</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key> <key>UIRequiredDeviceCapabilities</key>
@@ -46,13 +47,6 @@
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
</dict> </dict>
+2
View File
@@ -33,5 +33,7 @@
<array/> <array/>
<key>NSPrivacyTracking</key> <key>NSPrivacyTracking</key>
<false/> <false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct HaptikaWatchApp: App {
@StateObject private var model = WatchAppModel()
var body: some Scene {
WindowGroup {
WatchRootView()
.environmentObject(model)
.task { model.start() }
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import Combine
import Foundation
import WatchKit
@MainActor
public final class WatchAppModel: ObservableObject {
@Published public var hasCompletedOnboarding: Bool {
didSet { defaults.set(hasCompletedOnboarding, forKey: onboardingKey) }
}
@Published public var selectedTab: WatchHomeDestination?
public let store: WatchStore
public let measurement: WatchHealthMeasurementService
public let haptics: WatchHapticService
public let connectivity: WatchConnectivityService
private let defaults: UserDefaults
private let onboardingKey = "haptika.watch.onboarding.completed.v1"
private let deviceIDKey = "haptika.watch.device.id.v1"
private var sequence: UInt64
public var deviceID: String {
if let value = defaults.string(forKey: deviceIDKey) { return value }
let value = UUID().uuidString
defaults.set(value, forKey: deviceIDKey)
return value
}
public init(
defaults: UserDefaults = .standard,
store: WatchStore? = nil,
measurement: WatchHealthMeasurementService? = nil,
haptics: WatchHapticService? = nil,
connectivity: WatchConnectivityService? = nil
) {
self.defaults = defaults
self.store = store ?? WatchStore(defaults: defaults)
self.measurement = measurement ?? WatchHealthMeasurementService()
self.haptics = haptics ?? WatchHapticService()
self.connectivity = connectivity ?? WatchConnectivityService()
hasCompletedOnboarding = defaults.bool(forKey: onboardingKey)
sequence = UInt64(defaults.integer(forKey: "haptika.watch.sequence.v1"))
wireServices()
}
public func start() {
connectivity.activate()
connectivity.flush(outbox: store.outbox)
}
public func enqueueMeasurement(_ measurement: WatchMeasurementSummaryV1) {
guard let envelope = makeEnvelope(kind: .measurementUpsert, payload: measurement) else {
store.upsert(measurement)
return
}
store.upsert(measurement, enqueue: envelope)
connectivity.transfer(envelope)
}
public func enqueueSession(_ session: WatchRegulationSessionV1) {
guard let envelope = makeEnvelope(kind: .regulationSessionUpsert, payload: session) else {
store.upsert(session)
return
}
store.upsert(session, enqueue: envelope)
connectivity.transfer(envelope)
}
public func deleteMeasurement(_ measurement: WatchMeasurementSummaryV1) {
let tombstone = WatchDeletionTombstoneV1(entityID: measurement.id, entityKind: "measurement")
let envelope = makeEnvelope(kind: .tombstone, payload: tombstone)
_ = store.deleteMeasurement(id: measurement.id, envelope: envelope)
if let envelope { connectivity.transfer(envelope) }
}
public func saveFeeling(_ feeling: Int, for measurement: WatchMeasurementSummaryV1) {
var updated = measurement
updated.feeling = min(5, max(1, feeling))
updated.feelingRecordedAt = Date()
updated.updatedAt = Date()
enqueueMeasurement(updated)
}
private func wireServices() {
measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) }
measurement.onLiveHeartRate = { [weak self] sample in
guard let self,
let envelope = self.makeEnvelope(
kind: .liveHeartRate,
payload: WatchLiveHeartRateV1(bpm: sample.bpm, sampledAt: sample.date)
) else { return }
self.connectivity.sendLive(envelope)
}
connectivity.onAcknowledgement = { [weak self] ids in self?.store.acknowledge(eventIDs: ids) }
connectivity.onEnvelope = { [weak self] envelope in
guard let self else { return }
if envelope.kind == .preferences,
let preferences = try? envelope.decodePayload(WatchPreferencesV1.self) {
self.store.preferences = preferences
} else if envelope.kind == .measurementUpsert,
let measurement = try? envelope.decodePayload(WatchMeasurementSummaryV1.self) {
self.store.upsert(measurement)
} else if envelope.kind == .regulationSessionUpsert,
let session = try? envelope.decodePayload(WatchRegulationSessionV1.self) {
self.store.upsert(session)
} else if envelope.kind == .tombstone,
let tombstone = try? envelope.decodePayload(WatchDeletionTombstoneV1.self) {
self.store.apply(tombstone)
}
}
}
private func makeEnvelope<Payload: Encodable>(kind: WatchSyncKind, payload: Payload) -> WatchSyncEnvelopeV1? {
sequence += 1
defaults.set(Int(sequence), forKey: "haptika.watch.sequence.v1")
return try? .init(deviceID: deviceID, sequence: sequence, kind: kind, payload: payload)
}
}
public enum WatchHomeDestination: String, Hashable, Identifiable {
case measure
case coherence
case exercises
case cardiacMirror
case history
public var id: String { rawValue }
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.922",
"green" : "0.741",
"red" : "0.314"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,181 @@
import Foundation
public enum RegulationFamily: String, Codable, Sendable {
case breathe
case ground
case recenter
}
public enum RegulationMode: String, Codable, Sendable {
case haptic
case liveHeartRate
}
public enum RegulationPhaseKind: String, Codable, Sendable {
case inhale
case hold
case exhale
case anchor
}
public struct RegulationPhase: Codable, Equatable, Sendable, Identifiable {
public var id: String { "\(kind.rawValue)-\(durationSeconds)-\(label)" }
public let kind: RegulationPhaseKind
public let durationSeconds: TimeInterval
public let label: String
public init(kind: RegulationPhaseKind, durationSeconds: TimeInterval, label: String) {
self.kind = kind
self.durationSeconds = durationSeconds
self.label = label
}
}
public struct WatchRegulationProtocol: Codable, Equatable, Sendable, Identifiable {
public let id: String
public let version: Int
public let family: RegulationFamily
public let mode: RegulationMode
public let eyebrow: String
public let title: String
public let summary: String
public let durationSeconds: TimeInterval
public let phases: [RegulationPhase]
public init(
id: String,
version: Int = 1,
family: RegulationFamily,
mode: RegulationMode,
eyebrow: String,
title: String,
summary: String,
durationSeconds: TimeInterval,
phases: [RegulationPhase]
) {
self.id = id
self.version = version
self.family = family
self.mode = mode
self.eyebrow = eyebrow
self.title = title
self.summary = summary
self.durationSeconds = durationSeconds
self.phases = phases
}
}
public enum WatchProtocolCatalog {
private struct Manifest: Decodable {
let schemaVersion: Int
let protocols: [ManifestProtocol]
}
private struct ManifestProtocol: Decodable {
let kind: String
let family: RegulationFamily
let mode: String
let eyebrow: String
let title: String
let description: String
let durationSeconds: TimeInterval
let phases: [RegulationPhase]
var watchProtocol: WatchRegulationProtocol {
WatchRegulationProtocol(
id: kind,
version: 1,
family: family,
mode: mode == "haptic" ? .haptic : .liveHeartRate,
eyebrow: eyebrow,
title: title,
summary: description,
durationSeconds: durationSeconds,
phases: phases
)
}
}
public static let protocols: [WatchRegulationProtocol] = loadBundledManifest() ?? fallbackProtocols
private static let fallbackProtocols: [WatchRegulationProtocol] = [
.init(id: "myCoherence", family: .breathe, mode: .liveHeartRate, eyebrow: "BIOFEEDBACK", title: "Ma Cohérence", summary: "Respiration visuelle 5–5 avec tendance cardiaque.", durationSeconds: 180, phases: [
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 5, label: "Expirez"),
]),
.init(id: "coherence55", family: .breathe, mode: .haptic, eyebrow: "ÉQUILIBRE", title: "Cohérence 5–5", summary: "Une respiration régulière pour retrouver un rythme calme.", durationSeconds: 300, phases: [
.init(kind: .inhale, durationSeconds: 5, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 5, label: "Expirez lentement"),
]),
.init(id: "box4444", family: .breathe, mode: .haptic, eyebrow: "STABILITÉ", title: "Respiration carrée", summary: "Quatre temps égaux pour structurer l’attention.", durationSeconds: 240, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .hold, durationSeconds: 4, label: "Gardez l’air"),
.init(kind: .exhale, durationSeconds: 4, label: "Expirez"),
.init(kind: .hold, durationSeconds: 4, label: "Restez vide"),
]),
.init(id: "breathing478", family: .breathe, mode: .haptic, eyebrow: "RALENTISSEMENT", title: "Respiration 4–7–8", summary: "Un cycle court avec une expiration longue.", durationSeconds: 76, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .hold, durationSeconds: 7, label: "Gardez l’air"),
.init(kind: .exhale, durationSeconds: 8, label: "Expirez lentement"),
]),
.init(id: "relax46", family: .breathe, mode: .haptic, eyebrow: "RELÂCHEMENT", title: "Expiration 4–6", summary: "Une expiration plus longue pour accompagner la détente.", durationSeconds: 180, phases: [
.init(kind: .inhale, durationSeconds: 4, label: "Inspirez"),
.init(kind: .exhale, durationSeconds: 6, label: "Expirez lentement"),
]),
.init(id: "sosStress", family: .recenter, mode: .haptic, eyebrow: "RETOUR AU CALME", title: "SOS tension", summary: "Trois impulsions puis un repère toutes les dix secondes.", durationSeconds: 62, phases: [
.init(kind: .anchor, durationSeconds: 62, label: "Restez avec la sensation"),
]),
.init(id: "microAnchor", family: .ground, mode: .haptic, eyebrow: "FORMAT COURT", title: "Micro-ancrage", summary: "Un repère tactile discret pour revenir aux sensations.", durationSeconds: 37, phases: [
.init(kind: .anchor, durationSeconds: 37, label: "Sentez les points de contact"),
]),
]
public static func protocolWithID(_ id: String) -> WatchRegulationProtocol? {
protocols.first { $0.id == id }
}
private static func loadBundledManifest(bundle: Bundle = .main) -> [WatchRegulationProtocol]? {
guard let url = bundle.url(forResource: "regulation-protocols.v1", withExtension: "json"),
let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(Manifest.self, from: data),
manifest.schemaVersion == 1,
manifest.protocols.count == 7 else {
return nil
}
return manifest.protocols.map(\.watchProtocol)
}
}
public struct RegulationPhasePosition: Equatable, Sendable {
public let phaseIndex: Int
public let phase: RegulationPhase
public let progress: Double
public let cycleIndex: Int
}
public enum RegulationPhaseEngine {
public static func position(
in regulation: WatchRegulationProtocol,
elapsedSeconds: TimeInterval
) -> RegulationPhasePosition? {
guard !regulation.phases.isEmpty else { return nil }
let cycleDuration = regulation.phases.reduce(0) { $0 + $1.durationSeconds }
guard cycleDuration > 0 else { return nil }
let clamped = max(0, min(elapsedSeconds, max(0, regulation.durationSeconds - 0.001)))
let cycleElapsed = clamped.truncatingRemainder(dividingBy: cycleDuration)
var cursor: TimeInterval = 0
for (index, phase) in regulation.phases.enumerated() {
let end = cursor + phase.durationSeconds
if cycleElapsed < end || index == regulation.phases.count - 1 {
return .init(
phaseIndex: index,
phase: phase,
progress: min(1, max(0, (cycleElapsed - cursor) / phase.durationSeconds)),
cycleIndex: Int(clamped / cycleDuration)
)
}
cursor = end
}
return nil
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
public enum WatchBaselineMaturity: String, Codable, Sendable {
case unavailable
case provisional
case personalized
}
public enum WatchBaselineComparison: String, Codable, Sendable {
case unavailable
case usual
case slightlyDifferent
case clearlyDifferent
}
public struct WatchBaselineSnapshot: Codable, Equatable, Sendable {
public let maturity: WatchBaselineMaturity
public let comparableMeasurements: Int
public let medianBPM: Double?
public let medianAbsoluteDeviation: Double?
public init(measurements: [WatchMeasurementSummaryV1]) {
let values = measurements
.filter { $0.source == .appleWatchOptical && $0.isValid && $0.quality >= 0.65 && $0.context.isBaselineComparable }
.compactMap(\.averageBPM)
.filter(\.isFinite)
comparableMeasurements = values.count
medianBPM = Self.median(values)
medianAbsoluteDeviation = medianBPM.map { median in
Self.median(values.map { abs($0 - median) }) ?? 0
}
if values.count >= 10 {
maturity = .personalized
} else if values.count >= 5 {
maturity = .provisional
} else {
maturity = .unavailable
}
}
public func compare(bpm: Double?) -> WatchBaselineComparison {
guard maturity != .unavailable, let bpm, bpm.isFinite, let medianBPM else {
return .unavailable
}
let scale = max(medianAbsoluteDeviation ?? 0, 2)
let robustDistance = abs(bpm - medianBPM) / scale
if robustDistance < 2 { return .usual }
if robustDistance < 4 { return .slightlyDifferent }
return .clearlyDifferent
}
private static func median(_ values: [Double]) -> Double? {
guard !values.isEmpty else { return nil }
let sorted = values.sorted()
let middle = sorted.count / 2
return sorted.count.isMultiple(of: 2)
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
}
public extension WatchBaselineComparison {
var frenchLabel: String {
switch self {
case .unavailable: "Baseline en construction"
case .usual: "Habituel"
case .slightlyDifferent: "Légèrement différent"
case .clearlyDifferent: "Clairement différent"
}
}
}
@@ -0,0 +1,73 @@
import Foundation
public enum WatchMeasurementInvalidReason: String, Codable, CaseIterable, Sendable {
case noValidHeartRate
case insufficientSamples
case insufficientCoverage
case staleData
case interrupted
}
public struct WatchMeasurementValidation: Equatable, Sendable {
public let validSamples: [WatchHeartRateSampleV1]
public let currentBPM: Double?
public let averageBPM: Double?
public let minimumBPM: Double?
public let maximumBPM: Double?
public let coverageSeconds: TimeInterval
public let maximumGapSeconds: TimeInterval
public let quality: Double
public let invalidReasons: [WatchMeasurementInvalidReason]
public var isValid: Bool { invalidReasons.isEmpty }
}
public enum WatchMeasurementValidator {
public static let minimumBPM = 35.0
public static let maximumBPM = 220.0
public static let minimumSamples = 8
public static let minimumCoverageSeconds = 45.0
public static let maximumSilenceSeconds = 15.0
public static let targetDurationSeconds = 60.0
public static func validate(
samples: [WatchHeartRateSampleV1],
interrupted: Bool = false
) -> WatchMeasurementValidation {
let filtered = samples
.filter { $0.bpm.isFinite && (minimumBPM ... maximumBPM).contains($0.bpm) }
.sorted { $0.date < $1.date }
let coverage = max(0, (filtered.last?.date.timeIntervalSince(filtered.first?.date ?? Date())) ?? 0)
let gaps = zip(filtered, filtered.dropFirst()).map { $1.date.timeIntervalSince($0.date) }
let maximumGap = max(0, gaps.max() ?? 0)
let bpms = filtered.map(\.bpm)
let average = bpms.isEmpty ? nil : bpms.reduce(0, +) / Double(bpms.count)
var reasons: [WatchMeasurementInvalidReason] = []
if filtered.isEmpty { reasons.append(.noValidHeartRate) }
if filtered.count < minimumSamples { reasons.append(.insufficientSamples) }
if coverage < minimumCoverageSeconds { reasons.append(.insufficientCoverage) }
if maximumGap > maximumSilenceSeconds { reasons.append(.staleData) }
if interrupted { reasons.append(.interrupted) }
let sampleScore = min(1, Double(filtered.count) / Double(minimumSamples))
let coverageScore = min(1, coverage / targetDurationSeconds)
let continuityScore = maximumGap <= maximumSilenceSeconds
? 1
: max(0, maximumSilenceSeconds / maximumGap)
let quality = min(1, max(0, sampleScore * coverageScore * continuityScore))
return WatchMeasurementValidation(
validSamples: filtered,
currentBPM: filtered.last?.bpm,
averageBPM: average,
minimumBPM: bpms.min(),
maximumBPM: bpms.max(),
coverageSeconds: coverage,
maximumGapSeconds: maximumGap,
quality: quality,
invalidReasons: reasons
)
}
}
+377
View File
@@ -0,0 +1,377 @@
import Foundation
public enum MeasurementSource: String, Codable, CaseIterable, Sendable {
case iphoneCameraPPG
case appleWatchOptical
}
public struct WatchHeartRateSampleV1: Codable, Equatable, Sendable, Identifiable {
public var id: UUID
public var date: Date
public var bpm: Double
public init(id: UUID = UUID(), date: Date, bpm: Double) {
self.id = id
self.date = date
self.bpm = bpm
}
}
public struct WatchDeviceIdentityV1: Codable, Equatable, Sendable {
public var id: String
public var name: String
public var model: String?
public var systemVersion: String?
public init(id: String, name: String, model: String?, systemVersion: String?) {
self.id = id
self.name = name
self.model = model
self.systemVersion = systemVersion
}
private enum CodingKeys: String, CodingKey { case id, name, model, systemVersion }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
if let model { try container.encode(model, forKey: .model) } else { try container.encodeNil(forKey: .model) }
if let systemVersion { try container.encode(systemVersion, forKey: .systemVersion) } else { try container.encodeNil(forKey: .systemVersion) }
}
}
public enum WatchMeasurementPosture: String, Codable, CaseIterable, Sendable {
case seated
case standing
case lying
case unknown
}
public struct WatchMeasurementContextV1: Codable, Equatable, Sendable {
public var posture: WatchMeasurementPosture
public var isAtRest: Bool
public var recentCaffeine: Bool
public var recentMeal: Bool
public var recentExercise: Bool
public var note: String?
public init(
posture: WatchMeasurementPosture = .unknown,
isAtRest: Bool = true,
recentCaffeine: Bool = false,
recentMeal: Bool = false,
recentExercise: Bool = false,
note: String? = nil
) {
self.posture = posture
self.isAtRest = isAtRest
self.recentCaffeine = recentCaffeine
self.recentMeal = recentMeal
self.recentExercise = recentExercise
self.note = note
}
public var isBaselineComparable: Bool {
isAtRest && !recentCaffeine && !recentMeal && !recentExercise
}
}
public struct WatchMeasurementSummaryV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID
public var updatedAt: Date
public var startedAt: Date
public var endedAt: Date
public var source: MeasurementSource
public var device: WatchDeviceIdentityV1
public var currentBPM: Double?
public var averageBPM: Double?
public var minimumBPM: Double?
public var maximumBPM: Double?
public var samples: [WatchHeartRateSampleV1]
public var coverageSeconds: TimeInterval
public var quality: Double
public var isValid: Bool
public var invalidReasons: [WatchMeasurementInvalidReason]
public var context: WatchMeasurementContextV1
public var feeling: Int?
public var feelingRecordedAt: Date?
public init(
id: UUID = UUID(),
updatedAt: Date = Date(),
startedAt: Date,
endedAt: Date,
source: MeasurementSource = .appleWatchOptical,
device: WatchDeviceIdentityV1,
currentBPM: Double?,
averageBPM: Double?,
minimumBPM: Double?,
maximumBPM: Double?,
samples: [WatchHeartRateSampleV1],
coverageSeconds: TimeInterval,
quality: Double,
isValid: Bool,
invalidReasons: [WatchMeasurementInvalidReason] = [],
context: WatchMeasurementContextV1 = .init(),
feeling: Int? = nil,
feelingRecordedAt: Date? = nil
) {
self.id = id
self.updatedAt = updatedAt
self.startedAt = startedAt
self.endedAt = endedAt
self.source = source
self.device = device
self.currentBPM = currentBPM
self.averageBPM = averageBPM
self.minimumBPM = minimumBPM
self.maximumBPM = maximumBPM
self.samples = samples
self.coverageSeconds = coverageSeconds
self.quality = quality
self.isValid = isValid
self.invalidReasons = invalidReasons
self.context = context
self.feeling = feeling
self.feelingRecordedAt = feelingRecordedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, updatedAt, startedAt, endedAt, source, device
case currentBpm, averageBpm, minimumBpm, maximumBpm, samples, coverage, quality
case valid, invalidReason, context, feeling
}
private struct WireSample: Codable {
let offsetSeconds: TimeInterval
let bpm: Double
}
private struct WireContext: Codable {
let atRest: Bool
let posture: String?
let recentExercise: Bool?
let recentCaffeine: Bool?
let recentMeal: Bool?
}
private struct WireFeeling: Codable {
let score: Int
let recordedAt: Date
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(source, forKey: .source)
try container.encode(device, forKey: .device)
if let currentBPM { try container.encode(currentBPM, forKey: .currentBpm) }
else { try container.encodeNil(forKey: .currentBpm) }
try container.encodeIfPresent(averageBPM, forKey: .averageBpm)
try container.encodeIfPresent(minimumBPM, forKey: .minimumBpm)
try container.encodeIfPresent(maximumBPM, forKey: .maximumBpm)
try container.encode(samples.map { WireSample(offsetSeconds: max(0, $0.date.timeIntervalSince(startedAt)), bpm: $0.bpm) }, forKey: .samples)
try container.encode(min(1, max(0, coverageSeconds / 60)), forKey: .coverage)
try container.encode(quality, forKey: .quality)
try container.encode(isValid, forKey: .valid)
if let invalidReason = invalidReasons.first?.rawValue { try container.encode(invalidReason, forKey: .invalidReason) }
else { try container.encodeNil(forKey: .invalidReason) }
try container.encode(WireContext(
atRest: context.isAtRest,
posture: context.posture == .unknown ? nil : context.posture.rawValue,
recentExercise: context.recentExercise,
recentCaffeine: context.recentCaffeine,
recentMeal: context.recentMeal
), forKey: .context)
if let feeling {
try container.encode(WireFeeling(score: feeling, recordedAt: feelingRecordedAt ?? updatedAt), forKey: .feeling)
} else {
try container.encodeNil(forKey: .feeling)
}
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch measurement schema")
}
id = try container.decode(UUID.self, forKey: .id)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
source = try container.decode(MeasurementSource.self, forKey: .source)
device = try container.decode(WatchDeviceIdentityV1.self, forKey: .device)
currentBPM = try container.decodeIfPresent(Double.self, forKey: .currentBpm)
averageBPM = try container.decodeIfPresent(Double.self, forKey: .averageBpm)
minimumBPM = try container.decodeIfPresent(Double.self, forKey: .minimumBpm)
maximumBPM = try container.decodeIfPresent(Double.self, forKey: .maximumBpm)
let wireSamples = try container.decode([WireSample].self, forKey: .samples)
let decodedStartedAt = startedAt
samples = wireSamples.map { .init(date: decodedStartedAt.addingTimeInterval($0.offsetSeconds), bpm: $0.bpm) }
coverageSeconds = try container.decode(Double.self, forKey: .coverage) * 60
quality = try container.decode(Double.self, forKey: .quality)
isValid = try container.decode(Bool.self, forKey: .valid)
invalidReasons = (try container.decodeIfPresent(String.self, forKey: .invalidReason))
.flatMap(WatchMeasurementInvalidReason.init(rawValue:)).map { [$0] } ?? []
let wireContext = try container.decode(WireContext.self, forKey: .context)
context = .init(
posture: wireContext.posture.flatMap(WatchMeasurementPosture.init(rawValue:)) ?? .unknown,
isAtRest: wireContext.atRest,
recentCaffeine: wireContext.recentCaffeine ?? false,
recentMeal: wireContext.recentMeal ?? false,
recentExercise: wireContext.recentExercise ?? false
)
let wireFeeling = try container.decodeIfPresent(WireFeeling.self, forKey: .feeling)
feeling = wireFeeling?.score
feelingRecordedAt = wireFeeling?.recordedAt
}
}
public enum WatchRegulationSessionState: String, Codable, Sendable {
case inProgress
case awaitingAfter
case completed
case cancelled
}
public struct WatchRegulationSessionV1: Codable, Equatable, Sendable, Identifiable {
public static let currentSchemaVersion = 1
public let schemaVersion = 1
public var id: UUID
public var protocolID: String
public var protocolVersion: Int = 1
public var startedAt: Date
public var endedAt: Date
public var durationSeconds: TimeInterval
public var state: WatchRegulationSessionState
public var beforeMeasurementID: UUID?
public var afterMeasurementID: UUID?
public var feelingBefore: Int?
public var feelingAfter: Int?
public var healthKitWorkoutID: UUID?
public var updatedAt: Date
public init(
id: UUID = UUID(),
protocolID: String,
protocolVersion: Int = 1,
startedAt: Date,
endedAt: Date,
durationSeconds: TimeInterval,
state: WatchRegulationSessionState,
beforeMeasurementID: UUID? = nil,
afterMeasurementID: UUID? = nil,
feelingBefore: Int? = nil,
feelingAfter: Int? = nil,
healthKitWorkoutID: UUID? = nil,
updatedAt: Date = Date()
) {
self.id = id
self.protocolID = protocolID
self.protocolVersion = protocolVersion
self.startedAt = startedAt
self.endedAt = endedAt
self.durationSeconds = durationSeconds
self.state = state
self.beforeMeasurementID = beforeMeasurementID
self.afterMeasurementID = afterMeasurementID
self.feelingBefore = feelingBefore
self.feelingAfter = feelingAfter
self.healthKitWorkoutID = healthKitWorkoutID
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, id, protocolID = "protocolId", startedAt, endedAt, durationSeconds, state
case beforeMeasurementID = "beforeMeasurementId", afterMeasurementID = "afterMeasurementId"
case feelingBefore, feelingAfter, healthKitWorkoutID = "workoutUUID", updatedAt
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(id, forKey: .id)
try container.encode(protocolID, forKey: .protocolID)
try container.encode(startedAt, forKey: .startedAt)
try container.encode(endedAt, forKey: .endedAt)
try container.encode(durationSeconds, forKey: .durationSeconds)
try container.encode(state, forKey: .state)
try container.encode(updatedAt, forKey: .updatedAt)
if let beforeMeasurementID { try container.encode(beforeMeasurementID, forKey: .beforeMeasurementID) }
else { try container.encodeNil(forKey: .beforeMeasurementID) }
if let afterMeasurementID { try container.encode(afterMeasurementID, forKey: .afterMeasurementID) }
else { try container.encodeNil(forKey: .afterMeasurementID) }
if let feelingBefore { try container.encode(feelingBefore, forKey: .feelingBefore) }
else { try container.encodeNil(forKey: .feelingBefore) }
if let feelingAfter { try container.encode(feelingAfter, forKey: .feelingAfter) }
else { try container.encodeNil(forKey: .feelingAfter) }
if let healthKitWorkoutID { try container.encode(healthKitWorkoutID, forKey: .healthKitWorkoutID) }
else { try container.encodeNil(forKey: .healthKitWorkoutID) }
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == 1 else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch session schema")
}
id = try container.decode(UUID.self, forKey: .id)
protocolID = try container.decode(String.self, forKey: .protocolID)
protocolVersion = 1
startedAt = try container.decode(Date.self, forKey: .startedAt)
endedAt = try container.decode(Date.self, forKey: .endedAt)
durationSeconds = try container.decode(TimeInterval.self, forKey: .durationSeconds)
state = try container.decode(WatchRegulationSessionState.self, forKey: .state)
beforeMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .beforeMeasurementID)
afterMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .afterMeasurementID)
feelingBefore = try container.decodeIfPresent(Int.self, forKey: .feelingBefore)
feelingAfter = try container.decodeIfPresent(Int.self, forKey: .feelingAfter)
healthKitWorkoutID = try container.decodeIfPresent(UUID.self, forKey: .healthKitWorkoutID)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
}
public struct WatchDeletionTombstoneV1: Codable, Equatable, Sendable, Identifiable {
public let schemaVersion = 1
public let id: UUID = UUID()
public let entityID: UUID
public let entityKind: String
public let deletedAt: Date
public let updatedAt: Date
public init(entityID: UUID, entityKind: String, deletedAt: Date = Date(), updatedAt: Date = Date()) {
self.entityID = entityID
self.entityKind = entityKind
self.deletedAt = deletedAt
self.updatedAt = updatedAt
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, entityID = "entityId", entityKind, deletedAt, updatedAt
}
}
public struct WatchPreferencesV1: Codable, Equatable, Sendable {
public var schemaVersion = 1
public var saveMindAndBodyWorkouts: Bool
public var protocolCatalogVersion: String
public var updatedAt: Date
public init(
saveMindAndBodyWorkouts: Bool = true,
protocolCatalogVersion: String = "1.0.0",
updatedAt: Date = Date()
) {
self.saveMindAndBodyWorkouts = saveMindAndBodyWorkouts
self.protocolCatalogVersion = protocolCatalogVersion
self.updatedAt = updatedAt
}
}
@@ -0,0 +1,40 @@
import Foundation
public enum WatchExclusiveResource: String, Codable, Sendable {
case heartRateMeasurement
case hapticRegulation
}
public enum WatchResourceError: LocalizedError, Equatable {
case busy(WatchExclusiveResource)
case releaseMismatch
public var errorDescription: String? {
switch self {
case let .busy(resource): "Une activité \(resource.rawValue) est déjà en cours."
case .releaseMismatch: "La ressource libérée ne correspond pas à l’activité en cours."
}
}
}
public actor WatchResourceArbiter {
public static let shared = WatchResourceArbiter()
public private(set) var current: WatchExclusiveResource?
public init() {}
public func acquire(_ resource: WatchExclusiveResource) throws {
if let current { throw WatchResourceError.busy(current) }
current = resource
}
public func release(_ resource: WatchExclusiveResource) throws {
guard current == resource else { throw WatchResourceError.releaseMismatch }
current = nil
}
public func forceRelease() {
current = nil
}
}
+134
View File
@@ -0,0 +1,134 @@
import Combine
import Foundation
@MainActor
public final class WatchStore: ObservableObject {
@Published public private(set) var measurements: [WatchMeasurementSummaryV1] = []
@Published public private(set) var sessions: [WatchRegulationSessionV1] = []
@Published public private(set) var outbox: [WatchSyncEnvelopeV1] = []
@Published public private(set) var tombstones: [WatchDeletionTombstoneV1] = []
@Published public var preferences: WatchPreferencesV1 {
didSet { persist() }
}
public var baseline: WatchBaselineSnapshot { .init(measurements: measurements) }
private let defaults: UserDefaults
private let storageKey: String
private struct Snapshot: Codable {
var measurements: [WatchMeasurementSummaryV1]
var sessions: [WatchRegulationSessionV1]
var outbox: [WatchSyncEnvelopeV1]
var tombstones: [WatchDeletionTombstoneV1]
var preferences: WatchPreferencesV1
}
public init(defaults: UserDefaults = .standard, storageKey: String = "haptika.watch.store.v1") {
self.defaults = defaults
self.storageKey = storageKey
if let data = defaults.data(forKey: storageKey),
let snapshot = try? WatchCoding.decoder.decode(Snapshot.self, from: data) {
measurements = snapshot.measurements
sessions = snapshot.sessions
outbox = snapshot.outbox
tombstones = snapshot.tombstones
preferences = snapshot.preferences
} else {
preferences = .init()
}
}
public func upsert(_ measurement: WatchMeasurementSummaryV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == measurement.id }),
tombstone.updatedAt >= measurement.updatedAt { return }
measurements.removeAll { $0.id == measurement.id && $0.updatedAt <= measurement.updatedAt }
if !measurements.contains(where: { $0.id == measurement.id }) {
measurements.append(measurement)
}
measurements.sort { $0.startedAt > $1.startedAt }
measurements = Array(measurements.prefix(30))
if let envelope { enqueue(envelope) }
persist()
}
public func upsert(_ session: WatchRegulationSessionV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) {
if let tombstone = tombstones.first(where: { $0.entityID == session.id }),
tombstone.updatedAt >= session.updatedAt { return }
sessions.removeAll { $0.id == session.id && $0.updatedAt <= session.updatedAt }
if !sessions.contains(where: { $0.id == session.id }) {
sessions.append(session)
}
sessions.sort { $0.startedAt > $1.startedAt }
sessions = Array(sessions.prefix(20))
if let envelope { enqueue(envelope) }
persist()
}
@discardableResult
public func deleteMeasurement(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
measurements.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "measurement")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
@discardableResult
public func deleteSession(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 {
sessions.removeAll { $0.id == id }
let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "regulationSession")
tombstones.removeAll { $0.entityID == id }
tombstones.append(tombstone)
if let envelope { enqueue(envelope) }
persist()
return tombstone
}
public func apply(_ tombstone: WatchDeletionTombstoneV1) {
if tombstone.entityKind == "measurement" {
measurements.removeAll { $0.id == tombstone.entityID }
} else if tombstone.entityKind == "regulationSession" {
sessions.removeAll { $0.id == tombstone.entityID }
}
if let existing = tombstones.first(where: { $0.entityID == tombstone.entityID }),
existing.updatedAt >= tombstone.updatedAt { return }
tombstones.removeAll { $0.entityID == tombstone.entityID }
tombstones.append(tombstone)
persist()
}
public func enqueue(_ envelope: WatchSyncEnvelopeV1) {
guard !outbox.contains(where: { $0.eventID == envelope.eventID }) else { return }
outbox.append(envelope)
persist()
}
public func acknowledge(eventIDs: Set<UUID>) {
outbox.removeAll { eventIDs.contains($0.eventID) }
persist()
}
public func replaceFromCompanion(
measurements incomingMeasurements: [WatchMeasurementSummaryV1],
sessions incomingSessions: [WatchRegulationSessionV1]
) {
for measurement in incomingMeasurements { upsert(measurement) }
for session in incomingSessions { upsert(session) }
}
private func persist() {
let snapshot = Snapshot(
measurements: measurements,
sessions: sessions,
outbox: outbox,
tombstones: tombstones,
preferences: preferences
)
if let data = try? WatchCoding.encoder.encode(snapshot) {
defaults.set(data, forKey: storageKey)
}
}
}
@@ -0,0 +1,169 @@
import Foundation
public enum WatchSyncKind: String, Codable, Sendable {
case liveHeartRate
case measurementUpsert
case regulationSessionUpsert
case feelingUpsert
case tombstone
case preferences
case acknowledgement = "ack"
}
/// The payload is encoded JSON data. Keeping the transport wrapper non-generic lets the
/// offline outbox contain measurements, sessions and tombstones in a single queue.
public struct WatchSyncEnvelopeV1: Codable, Equatable, Sendable, Identifiable {
public static let schemaVersion = 1
public var id: UUID { eventID }
public let eventID: UUID
public let deviceID: String
public let sequence: UInt64
public let sentAt: Date
public let kind: WatchSyncKind
public let payload: Data
public init(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Data
) {
self.eventID = eventID
self.deviceID = deviceID
self.sequence = sequence
self.sentAt = sentAt
self.kind = kind
self.payload = payload
}
public init<Payload: Encodable>(
eventID: UUID = UUID(),
deviceID: String,
sequence: UInt64,
sentAt: Date = Date(),
kind: WatchSyncKind,
payload: Payload,
encoder: JSONEncoder = WatchCoding.encoder
) throws {
self.init(
eventID: eventID,
deviceID: deviceID,
sequence: sequence,
sentAt: sentAt,
kind: kind,
payload: try encoder.encode(payload)
)
}
public func decodePayload<Payload: Decodable>(
_ type: Payload.Type,
decoder: JSONDecoder = WatchCoding.decoder
) throws -> Payload {
try decoder.decode(type, from: payload)
}
private enum CodingKeys: String, CodingKey {
case schemaVersion, eventID = "eventId", deviceID = "deviceId", sequence, sentAt, kind, payload
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(Self.schemaVersion, forKey: .schemaVersion)
try container.encode(eventID, forKey: .eventID)
try container.encode(deviceID, forKey: .deviceID)
try container.encode(sequence, forKey: .sequence)
try container.encode(sentAt, forKey: .sentAt)
try container.encode(kind, forKey: .kind)
try container.encode(try WatchJSONValue(data: payload), forKey: .payload)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else {
throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch envelope schema")
}
eventID = try container.decode(UUID.self, forKey: .eventID)
deviceID = try container.decode(String.self, forKey: .deviceID)
sequence = try container.decode(UInt64.self, forKey: .sequence)
sentAt = try container.decode(Date.self, forKey: .sentAt)
kind = try container.decode(WatchSyncKind.self, forKey: .kind)
payload = try container.decode(WatchJSONValue.self, forKey: .payload).data()
}
}
private enum WatchJSONValue: Codable, Equatable {
case object([String: WatchJSONValue])
case array([WatchJSONValue])
case string(String)
case number(Double)
case bool(Bool)
case null
init(data: Data) throws {
self = try WatchCoding.decoder.decode(Self.self, from: data)
}
func data() throws -> Data { try WatchCoding.encoder.encode(self) }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { self = .null }
else if let value = try? container.decode(Bool.self) { self = .bool(value) }
else if let value = try? container.decode(Double.self) { self = .number(value) }
else if let value = try? container.decode(String.self) { self = .string(value) }
else if let value = try? container.decode([WatchJSONValue].self) { self = .array(value) }
else { self = .object(try container.decode([String: WatchJSONValue].self)) }
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .object(value): try container.encode(value)
case let .array(value): try container.encode(value)
case let .string(value): try container.encode(value)
case let .number(value): try container.encode(value)
case let .bool(value): try container.encode(value)
case .null: try container.encodeNil()
}
}
}
public struct WatchAcknowledgementV1: Codable, Equatable, Sendable {
public let eventIDs: [UUID]
public let receivedAt: Date
public init(eventIDs: [UUID], receivedAt: Date = Date()) {
self.eventIDs = eventIDs
self.receivedAt = receivedAt
}
}
public struct WatchLiveHeartRateV1: Codable, Equatable, Sendable {
public let bpm: Double
public let sampledAt: Date
public let measurementID: UUID?
public init(bpm: Double, sampledAt: Date, measurementID: UUID? = nil) {
self.bpm = bpm
self.sampledAt = sampledAt
self.measurementID = measurementID
}
}
public enum WatchCoding {
public static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]
return encoder
}
public static var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Haptika</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHealthShareUsageDescription</key>
<string>Haptika lit votre fréquence cardiaque pendant une mesure ou une séance guidée afin d’afficher votre évolution en direct.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Haptika peut enregistrer dans Santé les séances guidées Corps et esprit que vous terminez, si vous activez cette option.</string>
<key>WKApplication</key>
<true/>
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.sachagaulin.haptika.r537r56465.dev</string>
</dict>
</plist>
@@ -0,0 +1,129 @@
import Combine
import Foundation
import WatchConnectivity
@MainActor
public final class WatchConnectivityService: NSObject, ObservableObject {
@Published public private(set) var activationState: WCSessionActivationState = .notActivated
@Published public private(set) var isReachable = false
@Published public private(set) var lastSynchronizedAt: Date?
@Published public private(set) var lastError: String?
public var onEnvelope: ((WatchSyncEnvelopeV1) -> Void)?
public var onAcknowledgement: ((Set<UUID>) -> Void)?
private let session: WCSession?
private let encoder = WatchCoding.encoder
private let decoder = WatchCoding.decoder
public override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
}
public func activate() {
guard let session else { return }
session.delegate = self
session.activate()
activationState = session.activationState
isReachable = session.isReachable
}
public func sendLive(_ envelope: WatchSyncEnvelopeV1) {
guard let session, session.isReachable else { return }
do {
let data = try encoder.encode(envelope)
session.sendMessageData(data, replyHandler: nil) { [weak self] error in
Task { @MainActor in self?.lastError = error.localizedDescription }
}
} catch {
lastError = error.localizedDescription
}
}
/// Guaranteed delivery for durable mutations. WatchConnectivity persists these while offline.
public func transfer(_ envelope: WatchSyncEnvelopeV1) {
guard let session else { return }
do {
session.transferUserInfo(["envelope": try encoder.encode(envelope)])
} catch {
lastError = error.localizedDescription
}
}
public func flush(outbox: [WatchSyncEnvelopeV1]) {
for envelope in outbox { transfer(envelope) }
}
public func updateApplicationContext(preferences: WatchPreferencesV1) {
guard let session else { return }
do {
try session.updateApplicationContext(["preferences": try encoder.encode(preferences)])
} catch {
lastError = error.localizedDescription
}
}
private func receive(_ data: Data) {
do {
let envelope = try decoder.decode(WatchSyncEnvelopeV1.self, from: data)
lastSynchronizedAt = Date()
if envelope.kind == .acknowledgement,
let acknowledgement = try? envelope.decodePayload(WatchAcknowledgementV1.self) {
onAcknowledgement?(Set(acknowledgement.eventIDs))
} else {
onEnvelope?(envelope)
}
} catch {
lastError = error.localizedDescription
}
}
}
extension WatchConnectivityService: WCSessionDelegate {
nonisolated public func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
Task { @MainActor [weak self] in
self?.activationState = activationState
self?.isReachable = session.isReachable
self?.lastError = error?.localizedDescription
}
}
nonisolated public func sessionReachabilityDidChange(_ session: WCSession) {
Task { @MainActor [weak self] in self?.isReachable = session.isReachable }
}
nonisolated public func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
Task { @MainActor [weak self] in self?.receive(messageData) }
}
nonisolated public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
guard let data = userInfo["envelope"] as? Data else { return }
Task { @MainActor [weak self] in self?.receive(data) }
}
nonisolated public func session(
_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]
) {
guard let data = applicationContext["preferences"] as? Data else { return }
Task { @MainActor [weak self] in
guard let self,
let preferences = try? self.decoder.decode(WatchPreferencesV1.self, from: data) else { return }
let payload = try? self.encoder.encode(preferences)
if let payload {
self.onEnvelope?(.init(
deviceID: "iphone",
sequence: 0,
kind: .preferences,
payload: payload
))
}
self.lastSynchronizedAt = Date()
}
}
}
@@ -0,0 +1,127 @@
import Foundation
import WatchKit
@MainActor
public final class WatchHapticService: ObservableObject {
@Published public private(set) var isRunning = false
@Published public private(set) var elapsedSeconds: TimeInterval = 0
private let arbiter: WatchResourceArbiter
private var task: Task<Void, Never>?
private var activeRunID: UUID?
public init(arbiter: WatchResourceArbiter = .shared) {
self.arbiter = arbiter
}
public func start(protocol regulation: WatchRegulationProtocol) async throws {
task?.cancel()
task = nil
activeRunID = nil
await arbiter.forceRelease()
try await arbiter.acquire(.hapticRegulation)
let runID = UUID()
activeRunID = runID
isRunning = true
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
Task { @MainActor in await self.finishRun(runID) }
}
if regulation.id == "sosStress" {
for _ in 0..<3 {
guard !Task.isCancelled else { return }
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .milliseconds(600))
}
}
var previousPhaseIndex = -1
var previousCycleIndex = -1
let startedAt = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startedAt)
self.elapsedSeconds = min(regulation.durationSeconds, elapsed)
if elapsed >= regulation.durationSeconds {
WKInterfaceDevice.current().play(.success)
return
}
if regulation.id == "sosStress" {
let second = Int(elapsed.rounded(.down))
if second > 0 && second.isMultiple(of: 10) {
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .seconds(1))
continue
}
} else if let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed),
position.phaseIndex != previousPhaseIndex || position.cycleIndex != previousCycleIndex {
if previousPhaseIndex >= 0 { WKInterfaceDevice.current().play(.click) }
previousPhaseIndex = position.phaseIndex
previousCycleIndex = position.cycleIndex
}
try? await Task.sleep(for: .milliseconds(100))
}
}
}
public func startCardiacMirror(
initialBPM: Double,
durationSeconds: TimeInterval = 120,
decelerationBPMPerMinute: Double = 1.5
) async throws {
task?.cancel()
task = nil
activeRunID = nil
await arbiter.forceRelease()
try await arbiter.acquire(.hapticRegulation)
let runID = UUID()
activeRunID = runID
isRunning = true
elapsedSeconds = 0
WKInterfaceDevice.current().play(.start)
task = Task { @MainActor [weak self] in
guard let self else { return }
defer {
Task { @MainActor in await self.finishRun(runID) }
}
let startedAt = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startedAt)
self.elapsedSeconds = min(durationSeconds, elapsed)
guard elapsed < durationSeconds else {
WKInterfaceDevice.current().play(.success)
return
}
let targetBPM = max(35, initialBPM - decelerationBPMPerMinute * elapsed / 60)
WKInterfaceDevice.current().play(.click)
try? await Task.sleep(for: .seconds(60 / targetBPM))
}
}
}
public func cancel(playStop: Bool = true) {
guard task != nil || isRunning else { return }
task?.cancel()
task = nil
activeRunID = nil
isRunning = false
if playStop { WKInterfaceDevice.current().play(.stop) }
Task { @MainActor [weak self] in
guard let self, self.activeRunID == nil else { return }
await self.arbiter.forceRelease()
}
}
private func finishRun(_ runID: UUID) async {
guard activeRunID == runID else { return }
activeRunID = nil
task = nil
isRunning = false
try? await arbiter.release(.hapticRegulation)
}
}
@@ -0,0 +1,267 @@
import Combine
import Foundation
import HealthKit
import WatchKit
public enum WatchCollectionMode: Equatable, Sendable {
case technicalMeasurement
case guidedMindAndBody(saveToHealth: Bool)
}
public enum WatchMeasurementState: Equatable, Sendable {
case idle
case authorizing
case waitingForFirstSample(secondsRemaining: Int)
case collecting(progress: Double)
case finishing
case completed(WatchMeasurementSummaryV1)
case failed(String)
}
@MainActor
public final class WatchHealthMeasurementService: NSObject, ObservableObject {
@Published public private(set) var state: WatchMeasurementState = .idle
@Published public private(set) var currentBPM: Double?
@Published public private(set) var samples: [WatchHeartRateSampleV1] = []
@Published public private(set) var lastWorkoutUUID: UUID?
public var onLiveHeartRate: ((WatchHeartRateSampleV1) -> Void)?
public var onCompleted: ((WatchMeasurementSummaryV1) -> Void)?
private let healthStore: HKHealthStore
private let arbiter: WatchResourceArbiter
private var workoutSession: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private var collectionMode: WatchCollectionMode = .technicalMeasurement
private var startedAt: Date?
private var validCollectionStartedAt: Date?
private var timeoutTask: Task<Void, Never>?
private var finishTask: Task<Void, Never>?
private var collectionDurationSeconds: TimeInterval = 60
public init(
healthStore: HKHealthStore = HKHealthStore(),
arbiter: WatchResourceArbiter = .shared
) {
self.healthStore = healthStore
self.arbiter = arbiter
super.init()
}
public func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable(),
let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate) else {
throw NSError(domain: "HaptikaWatch.Health", code: 1, userInfo: [NSLocalizedDescriptionKey: "Santé n’est pas disponible sur cette Watch."])
}
state = .authorizing
try await healthStore.requestAuthorization(
toShare: [HKObjectType.workoutType()],
read: [heartRate]
)
state = .idle
}
public func start(
mode: WatchCollectionMode = .technicalMeasurement,
collectionDurationSeconds: TimeInterval = 60
) async throws {
guard case .idle = state else { return }
try await arbiter.acquire(.heartRateMeasurement)
do {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .mindAndBody
configuration.locationType = .unknown
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
session.delegate = self
builder.delegate = self
workoutSession = session
self.builder = builder
collectionMode = mode
self.collectionDurationSeconds = max(60, collectionDurationSeconds)
samples = []
currentBPM = nil
lastWorkoutUUID = nil
let now = Date()
startedAt = now
validCollectionStartedAt = nil
state = .waitingForFirstSample(secondsRemaining: 30)
session.startActivity(with: now)
try await builder.beginCollection(at: now)
scheduleFirstSampleTimeout()
} catch {
await arbiter.forceRelease()
state = .failed(error.localizedDescription)
throw error
}
}
public func cancel() {
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let builder = builder
Task { @MainActor [weak self] in
if let builder {
try? await builder.endCollection(at: Date())
builder.discardWorkout()
}
await self?.arbiter.forceRelease()
self?.resetResources()
self?.state = .idle
}
}
public func finish(interrupted: Bool = false) {
guard builder != nil else { return }
state = .finishing
timeoutTask?.cancel()
finishTask?.cancel()
workoutSession?.end()
let endDate = Date()
let startDate = startedAt ?? endDate
let validation = WatchMeasurementValidator.validate(samples: samples, interrupted: interrupted)
let summary = WatchMeasurementSummaryV1(
startedAt: startDate,
endedAt: endDate,
device: WatchDeviceIdentityV1(
id: WKInterfaceDevice.current().identifierForVendor?.uuidString ?? "unknown-watch",
name: WKInterfaceDevice.current().name,
model: WKInterfaceDevice.current().model,
systemVersion: WKInterfaceDevice.current().systemVersion
),
currentBPM: validation.currentBPM,
averageBPM: validation.averageBPM,
minimumBPM: validation.minimumBPM,
maximumBPM: validation.maximumBPM,
samples: validation.validSamples,
coverageSeconds: validation.coverageSeconds,
quality: validation.quality,
isValid: validation.isValid,
invalidReasons: validation.invalidReasons
)
let mode = collectionMode
let builder = self.builder
finishTask = Task { @MainActor [weak self] in
guard let self, let builder else { return }
do {
try await builder.endCollection(at: endDate)
switch mode {
case .technicalMeasurement:
builder.discardWorkout()
case let .guidedMindAndBody(saveToHealth):
if saveToHealth {
self.lastWorkoutUUID = try await builder.finishWorkout()?.uuid
} else {
builder.discardWorkout()
}
}
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .completed(summary)
self.onCompleted?(summary)
} catch {
builder.discardWorkout()
await self.arbiter.forceRelease()
self.resetResources(keepSamples: true)
self.state = .failed(error.localizedDescription)
}
}
}
public func resetAfterResult() {
guard workoutSession == nil else { return }
state = .idle
samples = []
currentBPM = nil
lastWorkoutUUID = nil
}
private func scheduleFirstSampleTimeout() {
timeoutTask?.cancel()
timeoutTask = Task { @MainActor [weak self] in
for remaining in stride(from: 30, through: 1, by: -1) {
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.state = .waitingForFirstSample(secondsRemaining: remaining)
try? await Task.sleep(for: .seconds(1))
}
guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return }
self.finish()
}
}
private func scheduleCollectionFinish(from firstSampleDate: Date) {
timeoutTask?.cancel()
finishTask?.cancel()
finishTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
let elapsed = Date().timeIntervalSince(firstSampleDate)
self.state = .collecting(progress: min(1, elapsed / self.collectionDurationSeconds))
if elapsed >= self.collectionDurationSeconds {
self.finish()
return
}
try? await Task.sleep(for: .milliseconds(250))
}
}
}
private func acceptHeartRate(_ bpm: Double, at date: Date) {
guard (WatchMeasurementValidator.minimumBPM ... WatchMeasurementValidator.maximumBPM).contains(bpm) else { return }
let sample = WatchHeartRateSampleV1(date: date, bpm: bpm)
samples.append(sample)
currentBPM = bpm
onLiveHeartRate?(sample)
if validCollectionStartedAt == nil {
validCollectionStartedAt = date
scheduleCollectionFinish(from: date)
}
}
private func resetResources(keepSamples: Bool = false) {
workoutSession = nil
builder = nil
startedAt = nil
validCollectionStartedAt = nil
timeoutTask = nil
finishTask = nil
if !keepSamples { samples = [] }
}
}
extension WatchHealthMeasurementService: HKWorkoutSessionDelegate {
nonisolated public func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {}
nonisolated public func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
Task { @MainActor [weak self] in
self?.finish(interrupted: true)
}
}
}
extension WatchHealthMeasurementService: HKLiveWorkoutBuilderDelegate {
nonisolated public func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
nonisolated public func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate),
collectedTypes.contains(heartRateType),
let statistics = workoutBuilder.statistics(for: heartRateType),
let quantity = statistics.mostRecentQuantity() else { return }
let unit = HKUnit.count().unitDivided(by: .minute())
let bpm = quantity.doubleValue(for: unit)
Task { @MainActor [weak self] in
self?.acceptHeartRate(bpm, at: Date())
}
}
}
@@ -0,0 +1,178 @@
import SwiftUI
struct WatchBeforeAfterFlowView: View {
enum Stage { case intro, before, beforeFeeling, exercise, after, afterFeeling, result }
@EnvironmentObject private var model: WatchAppModel
let regulation: WatchRegulationProtocol
@State private var stage: Stage = .intro
@State private var before: WatchMeasurementSummaryV1?
@State private var after: WatchMeasurementSummaryV1?
@State private var exerciseStartedAt: Date?
@State private var flowError: String?
@State private var selectedFeeling = 3
@State private var feelingBefore: Int?
@State private var feelingAfter: Int?
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch stage {
case .intro:
Image(systemName: "arrow.left.arrow.right.heart.fill").font(.title).foregroundStyle(WatchTheme.accent)
Text("Avant · Exercice · Après").font(.headline).multilineTextAlignment(.center)
Text("Deux mesures identiques encadrent l’exercice pour comparer le BPM et la qualité, sans score de stress.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Button("Mesurer avant") { startMeasurement(stage: .before) }.buttonStyle(.borderedProminent)
case .before, .after:
measurementContent
case .beforeFeeling:
feelingContent(title: "Comment vous sentez-vous avant ?") {
feelingBefore = selectedFeeling
if var measurement = before {
measurement.feeling = selectedFeeling
measurement.feelingRecordedAt = Date()
measurement.updatedAt = Date()
before = measurement
model.enqueueMeasurement(measurement)
}
stage = .exercise
exerciseStartedAt = Date()
Task {
do { try await model.haptics.start(protocol: regulation) }
catch { flowError = error.localizedDescription }
}
}
case .exercise:
if model.haptics.isRunning, let exerciseStartedAt {
BreathOrb(regulation: regulation, startedAt: exerciseStartedAt)
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
Button("Arrêter", role: .cancel) { model.haptics.cancel() }
} else {
Text("Exercice terminé").font(.headline)
Button("Mesurer après") { startMeasurement(stage: .after) }.buttonStyle(.borderedProminent)
}
case .afterFeeling:
feelingContent(title: "Comment vous sentez-vous après ?") {
feelingAfter = selectedFeeling
if var measurement = after {
measurement.feeling = selectedFeeling
measurement.feelingRecordedAt = Date()
measurement.updatedAt = Date()
after = measurement
model.enqueueMeasurement(measurement)
}
saveCompletedSession()
stage = .result
}
case .result:
resultContent
}
if let flowError { Text(flowError).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle("Comparaison")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, newState in handleMeasurementState(newState) }
}
@ViewBuilder
private var measurementContent: some View {
switch model.measurement.state {
case let .waitingForFirstSample(seconds):
ProgressView()
Text("Recherche du signal · \(seconds) s").font(.caption2)
case let .collecting(progress):
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
ProgressView(value: progress)
Text(stage == .before ? "Mesure avant" : "Mesure après").font(.caption2)
case .finishing:
ProgressView("Analyse…")
default:
ProgressView()
}
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
}
private var resultContent: some View {
VStack(spacing: 9) {
Text("Votre comparaison").font(.headline)
WatchCard {
VStack(alignment: .leading, spacing: 5) {
metricRow("Avant", before?.averageBPM)
metricRow("Après", after?.averageBPM)
if let beforeBPM = before?.averageBPM, let afterBPM = after?.averageBPM {
Divider()
Text(String(format: "Écart %+.0f BPM", afterBPM - beforeBPM)).foregroundStyle(WatchTheme.accent)
}
Text("Qualité : \(Int(((before?.quality ?? 0) * 100).rounded())) % → \(Int(((after?.quality ?? 0) * 100).rounded())) %")
if let feelingBefore, let feelingAfter {
Text("Ressenti : \(feelingBefore)/5 → \(feelingAfter)/5")
}
}
.font(.caption)
}
Text("Une variation ponctuelle n’est pas un diagnostic.").font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
private func metricRow(_ label: String, _ bpm: Double?) -> some View {
HStack {
Text(label)
Spacer()
Text(bpm.map { "\(Int($0.rounded())) BPM" } ?? "—")
}
}
private func startMeasurement(stage: Stage) {
self.stage = stage
model.measurement.resetAfterResult()
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch { flowError = error.localizedDescription }
}
}
private func feelingContent(title: String, onConfirm: @escaping () -> Void) -> some View {
VStack(spacing: 10) {
Text(title).font(.headline).multilineTextAlignment(.center)
Picker("Ressenti", selection: $selectedFeeling) {
ForEach(1...5, id: \.self) { score in Text("\(score) / 5").tag(score) }
}
Button("Continuer", action: onConfirm).buttonStyle(.borderedProminent)
}
}
private func saveCompletedSession() {
let endedAt = Date()
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: exerciseStartedAt ?? endedAt,
endedAt: endedAt,
durationSeconds: regulation.durationSeconds,
state: .completed,
beforeMeasurementID: before?.id,
afterMeasurementID: after?.id,
feelingBefore: feelingBefore,
feelingAfter: feelingAfter
))
}
private func handleMeasurementState(_ state: WatchMeasurementState) {
guard case let .completed(summary) = state else { return }
if stage == .before {
before = summary
model.measurement.resetAfterResult()
selectedFeeling = 3
stage = .beforeFeeling
} else if stage == .after {
after = summary
model.measurement.resetAfterResult()
selectedFeeling = 3
stage = .afterFeeling
}
}
}
@@ -0,0 +1,112 @@
import SwiftUI
struct WatchCardiacMirrorView: View {
enum Stage { case intro, measuring, ready, mirroring, completed }
@EnvironmentObject private var model: WatchAppModel
@State private var stage: Stage = .intro
@State private var initialBPM: Double?
@State private var errorMessage: String?
@State private var mirrorStartedAt: Date?
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch stage {
case .intro:
Image(systemName: "heart.circle.fill").font(.system(size: 40)).foregroundStyle(.pink)
Text("Miroir cardiaque").font(.headline)
Text(model.connectivity.isReachable ? "Mode connecté : la Watch mesure, l’iPhone peut reconstruire le rythme visuel et haptique." : "Mode Estimation Watch : mesure initiale, arrêt du capteur, puis rythme reconstruit et ralenti.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Button("Mesurer le rythme") { startMeasurement() }.buttonStyle(.borderedProminent)
case .measuring:
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold())
measurementProgress
Text("Aucune vibration pendant la mesure").font(.caption2).foregroundStyle(.orange)
Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro }
case .ready:
Text(initialBPM.map { "Repère initial \(Int($0.rounded())) BPM" } ?? "Repère indisponible").font(.headline)
Text("Le rythme suivant est une estimation reconstruite, pas chaque battement biologique.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
if model.connectivity.isReachable {
Label("iPhone connecté", systemImage: "iphone.and.arrow.forward").font(.caption).foregroundStyle(WatchTheme.mint)
Button("Terminer") { stage = .completed }
} else {
Button("Lancer le miroir") { startMirror() }.buttonStyle(.borderedProminent)
}
case .mirroring:
estimatedPulse
ProgressView(value: model.haptics.elapsedSeconds, total: 120)
Text("Ralentissement progressif · −1,5 BPM/min").font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Arrêter", role: .cancel) { model.haptics.cancel(); stage = .completed }
case .completed:
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Expérience terminée").font(.headline)
Button("Recommencer") { stage = .intro; model.measurement.resetAfterResult() }
}
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle("Miroir")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, state in
if case let .completed(summary) = state {
initialBPM = summary.averageBPM
stage = .ready
}
}
.onChange(of: model.haptics.isRunning) { wasRunning, isRunning in
if stage == .mirroring, wasRunning, !isRunning { stage = .completed }
}
.onDisappear {
if stage == .measuring { model.measurement.cancel() }
if stage == .mirroring { model.haptics.cancel() }
}
}
@ViewBuilder
private var measurementProgress: some View {
switch model.measurement.state {
case let .waitingForFirstSample(seconds):
ProgressView(); Text("Signal · \(seconds) s").font(.caption2)
case let .collecting(progress): ProgressView(value: progress)
case .finishing: ProgressView("Analyse…")
default: EmptyView()
}
}
private var estimatedPulse: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
let elapsed = mirrorStartedAt.map { context.date.timeIntervalSince($0) } ?? 0
let bpm = max(35, (initialBPM ?? 60) - 1.5 * elapsed / 60)
let phase = (elapsed * bpm / 60).truncatingRemainder(dividingBy: 1)
let scale = 0.78 + 0.22 * max(0, 1 - phase * 4)
VStack {
Image(systemName: "heart.fill").font(.system(size: 62)).foregroundStyle(.pink).scaleEffect(scale)
Text("≈ \(Int(bpm.rounded())) BPM").font(.headline.monospacedDigit())
}
}
}
private func startMeasurement() {
stage = .measuring
model.measurement.resetAfterResult()
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch { errorMessage = error.localizedDescription }
}
}
private func startMirror() {
guard let initialBPM else { return }
mirrorStartedAt = Date()
stage = .mirroring
Task {
do { try await model.haptics.startCardiacMirror(initialBPM: initialBPM) }
catch { errorMessage = error.localizedDescription; stage = .ready }
}
}
}
@@ -0,0 +1,138 @@
import Charts
import SwiftUI
struct WatchCoherenceView: View {
@EnvironmentObject private var model: WatchAppModel
@State private var selectedDuration = 180
@State private var startedAt: Date?
@State private var savedCompletion = false
private let regulation = WatchProtocolCatalog.protocolWithID("myCoherence")!
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch model.measurement.state {
case .idle, .authorizing:
Text("Respirez 5 s / 5 s").font(.headline)
Text("Le guide reste visuel : aucune vibration ne peut interrompre la mesure cardiaque.")
.font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Picker("Durée", selection: $selectedDuration) {
Text("3 min").tag(180)
Text("5 min").tag(300)
}
Button("Démarrer") { start() }.buttonStyle(.borderedProminent)
case let .waitingForFirstSample(seconds):
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date())
Text("Signal cardiaque… \(seconds) s").font(.caption2)
cancelButton
case let .collecting(progress):
BreathOrb(regulation: regulation, startedAt: startedAt ?? Date())
Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title3.bold())
heartRateTrend(samples: Array(model.measurement.samples.suffix(30)))
ProgressView(value: progress)
Text("Couverture \(Int(model.measurement.samples.last?.date.timeIntervalSince(model.measurement.samples.first?.date ?? Date()) ?? 0)) s")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
cancelButton
case .finishing:
ProgressView("Sauvegarde…")
case let .completed(summary):
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Séance terminée").font(.headline)
Text(summary.averageBPM.map { "Pouls moyen \(Int($0.rounded())) BPM" } ?? "Pouls non disponible")
.font(.caption)
heartRateTrend(samples: summary.samples)
Text("Couverture \(Int(summary.coverageSeconds)) s · tendance \(trendLabel(summary.samples))")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
Text("Aucun score de cohérence ou de PRV n’est inventé.").font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Terminer") { model.measurement.resetAfterResult() }.buttonStyle(.borderedProminent)
case let .failed(message):
Text("Séance interrompue").font(.headline)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Retour") { model.measurement.resetAfterResult() }
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Ma Cohérence")
.haptikaScreen()
.onChange(of: model.measurement.state) { _, newState in
guard case let .completed(summary) = newState, !savedCompletion, let startedAt else { return }
savedCompletion = true
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: startedAt,
endedAt: summary.endedAt,
durationSeconds: summary.endedAt.timeIntervalSince(startedAt),
state: .completed,
afterMeasurementID: summary.id,
healthKitWorkoutID: model.measurement.lastWorkoutUUID
))
}
}
private func start() {
startedAt = Date()
savedCompletion = false
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start(
mode: .guidedMindAndBody(saveToHealth: model.store.preferences.saveMindAndBodyWorkouts),
collectionDurationSeconds: TimeInterval(selectedDuration)
)
} catch {}
}
}
private var cancelButton: some View {
Button("Arrêter", role: .cancel) { model.measurement.cancel() }.buttonStyle(.bordered)
}
private func heartRateTrend(samples: [WatchHeartRateSampleV1]) -> some View {
Chart(samples) { sample in
LineMark(x: .value("Temps", sample.date), y: .value("BPM", sample.bpm))
.foregroundStyle(WatchTheme.mint)
.interpolationMethod(.catmullRom)
}
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.frame(height: 44)
}
private func trendLabel(_ samples: [WatchHeartRateSampleV1]) -> String {
guard samples.count >= 4 else { return "en construction" }
let midpoint = samples.count / 2
let first = samples.prefix(midpoint).map(\.bpm)
let last = samples.suffix(from: midpoint).map(\.bpm)
let delta = (last.reduce(0, +) / Double(last.count)) - (first.reduce(0, +) / Double(first.count))
if delta < -2 { return "en baisse" }
if delta > 2 { return "en hausse" }
return "stable"
}
}
struct BreathOrb: View {
let regulation: WatchRegulationProtocol
let startedAt: Date
var body: some View {
TimelineView(.animation(minimumInterval: 1 / 20)) { context in
let elapsed = context.date.timeIntervalSince(startedAt)
let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed)
let isExpanding = position?.phase.kind == .inhale
let fraction = position?.progress ?? 0
let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4
VStack(spacing: 6) {
ZStack {
Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108)
Circle()
.fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52))
.frame(width: 88, height: 88)
.scaleEffect(scale)
}
Text(position?.phase.label ?? "Respirez").font(.headline)
}
}
}
}
@@ -0,0 +1,102 @@
import SwiftUI
struct WatchExerciseListView: View {
private var hapticProtocols: [WatchRegulationProtocol] {
WatchProtocolCatalog.protocols.filter { $0.mode == .haptic }
}
var body: some View {
List(hapticProtocols) { regulation in
NavigationLink {
WatchExercisePlayerView(regulation: regulation)
} label: {
VStack(alignment: .leading, spacing: 3) {
Text(regulation.title).font(.subheadline.bold())
Text(formatDuration(regulation.durationSeconds)).font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
}
.navigationTitle("Exercices")
.haptikaScreen()
}
}
struct WatchExercisePlayerView: View {
@EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss
let regulation: WatchRegulationProtocol
@State private var startedAt: Date?
@State private var errorMessage: String?
@State private var sessionSaved = false
var body: some View {
ScrollView {
VStack(spacing: 10) {
if model.haptics.isRunning, let startedAt {
BreathOrb(regulation: regulation, startedAt: startedAt)
ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds)
Text("\(Int(max(0, regulation.durationSeconds - model.haptics.elapsedSeconds))) s")
.font(.caption.monospacedDigit()).foregroundStyle(WatchTheme.secondary)
Button("Arrêter", role: .cancel) {
saveSession(state: .cancelled)
model.haptics.cancel()
}
} else if startedAt != nil {
Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint)
Text("Exercice terminé").font(.headline)
Button("Terminer") { dismiss() }.buttonStyle(.borderedProminent)
.onAppear { saveSession(state: .completed) }
} else {
Text(regulation.eyebrow).font(.caption2.bold()).foregroundStyle(WatchTheme.accent)
Text(regulation.title).font(.headline).multilineTextAlignment(.center)
Text(regulation.summary).font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
Text("Aucune mesure cardiaque pendant les vibrations.").font(.caption2.bold()).foregroundStyle(.orange)
Button("Commencer") { start() }.buttonStyle(.borderedProminent)
NavigationLink("Parcours avant / après") {
WatchBeforeAfterFlowView(regulation: regulation)
}
.font(.caption)
}
if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) }
}
.padding(.horizontal, 4)
}
.navigationTitle(regulation.title)
.haptikaScreen()
.onDisappear {
if model.haptics.isRunning {
saveSession(state: .cancelled)
model.haptics.cancel()
}
}
}
private func start() {
let now = Date()
Task {
do {
try await model.haptics.start(protocol: regulation)
startedAt = now
} catch {
errorMessage = error.localizedDescription
}
}
}
private func saveSession(state: WatchRegulationSessionState) {
guard !sessionSaved, let startedAt else { return }
sessionSaved = true
model.enqueueSession(.init(
protocolID: regulation.id,
startedAt: startedAt,
endedAt: Date(),
durationSeconds: model.haptics.elapsedSeconds,
state: state
))
}
}
private func formatDuration(_ duration: TimeInterval) -> String {
let seconds = Int(duration)
return seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min \(seconds % 60 == 0 ? "" : "\(seconds % 60) s")"
}
@@ -0,0 +1,111 @@
import Charts
import SwiftUI
struct WatchHistoryView: View {
@EnvironmentObject private var model: WatchAppModel
private var recentSevenDays: [WatchMeasurementSummaryV1] {
let cutoff = Date().addingTimeInterval(-7 * 86_400)
return model.store.measurements.filter { $0.startedAt >= cutoff && $0.isValid }
}
var body: some View {
List {
if !recentSevenDays.isEmpty {
Section("Tendance 7 jours") {
Chart(recentSevenDays) { measurement in
LineMark(
x: .value("Date", measurement.startedAt),
y: .value("BPM", measurement.averageBPM ?? 0)
)
.foregroundStyle(WatchTheme.accent)
PointMark(
x: .value("Date", measurement.startedAt),
y: .value("BPM", measurement.averageBPM ?? 0)
)
.foregroundStyle(WatchTheme.mint)
}
.chartYAxis(.hidden)
.frame(height: 90)
}
}
Section("Mesures") {
if model.store.measurements.isEmpty {
Text("Aucune mesure synchronisée").font(.caption).foregroundStyle(WatchTheme.secondary)
}
ForEach(model.store.measurements) { measurement in
NavigationLink {
WatchMeasurementDetailView(measurement: measurement)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Incomplète").font(.subheadline.bold())
Text(measurement.startedAt, style: .date).font(.caption2).foregroundStyle(WatchTheme.secondary)
}
Spacer()
Text("\(Int((measurement.quality * 100).rounded())) %").font(.caption2).foregroundStyle(measurement.isValid ? WatchTheme.mint : .orange)
}
}
}
}
Section("Séances") {
if model.store.sessions.isEmpty {
Text("Aucune séance").font(.caption).foregroundStyle(WatchTheme.secondary)
}
ForEach(model.store.sessions) { session in
VStack(alignment: .leading, spacing: 2) {
Text(WatchProtocolCatalog.protocolWithID(session.protocolID)?.title ?? session.protocolID).font(.subheadline.bold())
Text(session.startedAt, style: .relative).font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
}
}
.navigationTitle("Historique")
.haptikaScreen()
}
}
struct WatchMeasurementDetailView: View {
@EnvironmentObject private var model: WatchAppModel
@Environment(\.dismiss) private var dismiss
let measurement: WatchMeasurementSummaryV1
@State private var confirmDelete = false
var body: some View {
List {
Section("Mesure Watch") {
metric("Moyenne", measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "—")
metric("Min / max", "\(Int(measurement.minimumBPM ?? 0)) / \(Int(measurement.maximumBPM ?? 0))")
metric("Qualité", "\(Int((measurement.quality * 100).rounded())) %")
metric("Couverture", "\(Int(measurement.coverageSeconds)) s")
metric("Appareil", measurement.device.name)
if let feeling = measurement.feeling { metric("Ressenti", "\(feeling) / 5") }
}
Section {
Text("Pouls optique Apple Watch. Pas de PRV ni de diagnostic médical.")
.font(.caption2).foregroundStyle(WatchTheme.secondary)
}
Button("Supprimer", role: .destructive) { confirmDelete = true }
}
.navigationTitle(measurement.startedAt.formatted(date: .abbreviated, time: .shortened))
.haptikaScreen()
.confirmationDialog("Supprimer cette mesure ?", isPresented: $confirmDelete) {
Button("Supprimer", role: .destructive) {
model.deleteMeasurement(measurement)
dismiss()
}
Button("Annuler", role: .cancel) {}
}
}
private func metric(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundStyle(WatchTheme.secondary)
Spacer()
Text(value).multilineTextAlignment(.trailing)
}
.font(.caption)
}
}
@@ -0,0 +1,115 @@
import SwiftUI
struct WatchMeasurementView: View {
@EnvironmentObject private var model: WatchAppModel
var body: some View {
ScrollView {
VStack(spacing: 10) {
switch model.measurement.state {
case .idle, .authorizing:
preparation
case let .waitingForFirstSample(secondsRemaining):
liveHeader(bpm: nil, caption: "Recherche du signal · \(secondsRemaining) s")
ProgressView()
cancelButton
case let .collecting(progress):
liveHeader(bpm: model.measurement.currentBPM, caption: "Mesure en cours")
ProgressView(value: progress)
Text("Restez immobile, bras détendu").font(.caption2).foregroundStyle(WatchTheme.secondary)
cancelButton
case .finishing:
ProgressView("Analyse…")
case let .completed(summary):
WatchMeasurementResultView(summary: summary)
case let .failed(message):
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Mesure interrompue").font(.headline)
Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary)
Button("Recommencer") { model.measurement.resetAfterResult() }
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Mesurer")
.haptikaScreen()
}
private var preparation: some View {
VStack(spacing: 10) {
Image(systemName: "applewatch.radiowaves.left.and.right").font(.system(size: 38)).foregroundStyle(WatchTheme.mint)
Text("60 secondes au repos").font(.headline)
Text("Serrez le bracelet sans gêne et posez l’avant-bras. Haptika attendra jusqu’à 30 s le premier BPM.")
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
.multilineTextAlignment(.center)
Button("Commencer") {
Task {
do {
try await model.measurement.requestAuthorization()
try await model.measurement.start()
} catch {}
}
}
.buttonStyle(.borderedProminent)
}
}
private func liveHeader(bpm: Double?, caption: String) -> some View {
VStack(spacing: 3) {
Image(systemName: "heart.fill").foregroundStyle(.pink).symbolEffect(.pulse, options: .repeating)
Text(bpm.map { "\(Int($0.rounded()))" } ?? "—")
.font(.system(size: 42, weight: .bold, design: .rounded))
Text(bpm == nil ? caption : "BPM · \(caption)").font(.caption2).foregroundStyle(WatchTheme.secondary)
}
}
private var cancelButton: some View {
Button("Arrêter", role: .cancel) { model.measurement.cancel() }
.buttonStyle(.bordered)
}
}
struct WatchMeasurementResultView: View {
@EnvironmentObject private var model: WatchAppModel
let summary: WatchMeasurementSummaryV1
private var comparison: WatchBaselineComparison {
model.store.baseline.compare(bpm: summary.averageBPM)
}
var body: some View {
VStack(spacing: 9) {
Image(systemName: summary.isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.title)
.foregroundStyle(summary.isValid ? WatchTheme.mint : .orange)
Text(summary.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Signal incomplet")
.font(.title3.bold())
WatchCard {
VStack(alignment: .leading, spacing: 5) {
Label("Qualité \(Int((summary.quality * 100).rounded())) %", systemImage: "checkmark.seal")
Text("\(Int(summary.minimumBPM ?? 0))–\(Int(summary.maximumBPM ?? 0)) BPM")
Text("Couverture \(Int(summary.coverageSeconds)) s")
Divider()
Text(comparison.frenchLabel).foregroundStyle(WatchTheme.accent)
}
.font(.caption)
}
Text("Mesure optique de bien-être, non médicale. Aucun indice PRV n’est calculé sur la Watch.")
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
if summary.isValid {
Text("Ressenti").font(.caption2).foregroundStyle(WatchTheme.secondary)
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { value in
Button("\(value)") { model.saveFeeling(value, for: summary) }
.buttonStyle(.borderless)
.foregroundStyle(summary.feeling == value ? WatchTheme.mint : .white)
}
}
}
Button("Terminer") { model.measurement.resetAfterResult() }
.buttonStyle(.borderedProminent)
}
}
}
+114
View File
@@ -0,0 +1,114 @@
import SwiftUI
struct WatchRootView: View {
@EnvironmentObject private var model: WatchAppModel
var body: some View {
Group {
if model.hasCompletedOnboarding {
WatchHomeView()
} else {
WatchOnboardingView()
}
}
.preferredColorScheme(.dark)
}
}
struct WatchOnboardingView: View {
@EnvironmentObject private var model: WatchAppModel
@State private var page = 0
@State private var authorizationError: String?
var body: some View {
VStack(spacing: 8) {
TabView(selection: $page) {
onboardingPage(icon: "hand.raised.fill", title: "Vos données", text: "Les mesures restent sur vos appareils et se synchronisent avec votre iPhone appairé.").tag(0)
onboardingPage(icon: "heart.text.square.fill", title: "Bien-être, pas diagnostic", text: "Haptika n’est pas un dispositif médical. Consultez un professionnel en cas de symptôme.").tag(1)
onboardingPage(icon: "waveform.path.ecg", title: "Mesurer sans vibrer", text: "La Watch mesure le pouls optique. Les vibrations sont toujours arrêtées pendant cette phase.").tag(2)
}
.tabViewStyle(.verticalPage)
if let authorizationError {
Text(authorizationError).font(.caption2).foregroundStyle(.orange)
}
Button(page < 2 ? "Continuer" : "Autoriser Santé") {
if page < 2 {
withAnimation { page += 1 }
} else {
Task {
do {
try await model.measurement.requestAuthorization()
model.hasCompletedOnboarding = true
} catch {
authorizationError = error.localizedDescription
}
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(.horizontal, 6)
.background(WatchTheme.background.ignoresSafeArea())
}
private func onboardingPage(icon: String, title: String, text: String) -> some View {
VStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent)
Text(title).font(.headline).multilineTextAlignment(.center)
Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center)
}
}
}
struct WatchHomeView: View {
@EnvironmentObject private var model: WatchAppModel
var body: some View {
NavigationStack {
List {
Section {
NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) }
NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) }
NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) }
NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) }
NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) }
} header: {
VStack(alignment: .leading, spacing: 2) {
Text("HAPTIKA").font(.caption2).foregroundStyle(WatchTheme.accent)
Text("Prendre un temps").font(.headline).foregroundStyle(.white)
}
.textCase(nil)
}
Section {
HStack {
Circle().fill(model.connectivity.isReachable ? WatchTheme.mint : .gray).frame(width: 7, height: 7)
Text(model.connectivity.isReachable ? "iPhone joignable" : "Mode autonome")
}
.font(.caption2)
.foregroundStyle(WatchTheme.secondary)
}
}
.navigationDestination(for: WatchHomeDestination.self) { destination in
switch destination {
case .measure: WatchMeasurementView()
case .coherence: WatchCoherenceView()
case .exercises: WatchExerciseListView()
case .cardiacMirror: WatchCardiacMirrorView()
case .history: WatchHistoryView()
}
}
.haptikaScreen()
}
}
private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View {
Label {
Text(title).font(.subheadline.weight(.semibold))
} icon: {
Image(systemName: icon).foregroundStyle(color)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import SwiftUI
enum WatchTheme {
static let background = Color(red: 0.015, green: 0.04, blue: 0.10)
static let card = Color(red: 0.035, green: 0.12, blue: 0.22)
static let accent = Color(red: 0.37, green: 0.79, blue: 0.96)
static let mint = Color(red: 0.38, green: 0.88, blue: 0.78)
static let secondary = Color(red: 0.66, green: 0.73, blue: 0.84)
}
struct WatchCard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
content
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22)))
}
}
extension View {
func haptikaScreen() -> some View {
scrollContentBackground(.hidden)
.background(WatchTheme.background.ignoresSafeArea())
.tint(WatchTheme.accent)
}
}
+126
View File
@@ -0,0 +1,126 @@
import XCTest
@testable import HaptikaWatch
final class WatchCoreTests: XCTestCase {
private let device = WatchDeviceIdentityV1(
id: "watch-series-10-test",
name: "Apple Watch de test",
model: "Watch7,8",
systemVersion: "11.0"
)
func testValidationAcceptsEightSamplesCoveringFortyFiveSeconds() {
let start = Date(timeIntervalSince1970: 1_700_000_000)
let samples = (0..<8).map { index in
WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(index) * 7), bpm: 70 + Double(index % 3))
}
let result = WatchMeasurementValidator.validate(samples: samples)
XCTAssertTrue(result.isValid)
XCTAssertEqual(result.validSamples.count, 8)
XCTAssertEqual(result.coverageSeconds, 49, accuracy: 0.001)
}
func testValidationRejectsOutOfRangeAndLongSilence() {
let start = Date(timeIntervalSince1970: 1_700_000_000)
let offsets: [TimeInterval] = [0, 5, 10, 15, 20, 25, 45, 50, 55]
var samples = offsets.map { WatchHeartRateSampleV1(date: start.addingTimeInterval($0), bpm: 72) }
samples.append(.init(date: start.addingTimeInterval(56), bpm: 221))
let result = WatchMeasurementValidator.validate(samples: samples)
XCTAssertFalse(result.isValid)
XCTAssertTrue(result.invalidReasons.contains(.staleData))
XCTAssertEqual(result.validSamples.count, 9)
}
func testBaselineMaturesAtFiveAndTenComparableMeasurements() {
let five = (0..<5).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) }
XCTAssertEqual(WatchBaselineSnapshot(measurements: five).maturity, .provisional)
let ten = (0..<10).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) }
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).maturity, .personalized)
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 71), .usual)
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 90), .clearlyDifferent)
}
func testBaselineExcludesLowQualityAndNonRestMeasurements() {
var lowQuality = measurement(index: 0, bpm: 70)
lowQuality.quality = 0.64
var afterExercise = measurement(index: 1, bpm: 71)
afterExercise.context.recentExercise = true
XCTAssertEqual(WatchBaselineSnapshot(measurements: [lowQuality, afterExercise]).comparableMeasurements, 0)
}
func testPhaseEngineMovesThroughBoxBreathing() throws {
let protocolValue = try XCTUnwrap(WatchProtocolCatalog.protocolWithID("box4444"))
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 1)?.phase.kind, .inhale)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 5)?.phase.kind, .hold)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 9)?.phase.kind, .exhale)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 13)?.phase.kind, .hold)
}
func testEnvelopeEncodesPayloadAsJSONObject() throws {
let summary = measurement(index: 0, bpm: 72)
let envelope = try WatchSyncEnvelopeV1(
eventID: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!,
deviceID: device.id,
sequence: 42,
kind: .measurementUpsert,
payload: summary
)
let data = try WatchCoding.encoder.encode(envelope)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertEqual(object["schemaVersion"] as? Int, 1)
XCTAssertNotNil(object["payload"] as? [String: Any])
XCTAssertNil(object["payload"] as? String, "Payload must not be a base64 Data string")
let decoded = try WatchCoding.decoder.decode(WatchSyncEnvelopeV1.self, from: data)
let decodedSummary = try decoded.decodePayload(WatchMeasurementSummaryV1.self)
XCTAssertEqual(decodedSummary.id, summary.id)
}
func testResourceArbiterExcludesHapticsDuringMeasurement() async throws {
let arbiter = WatchResourceArbiter()
try await arbiter.acquire(.heartRateMeasurement)
do {
try await arbiter.acquire(.hapticRegulation)
XCTFail("Second resource must be rejected")
} catch let error as WatchResourceError {
XCTAssertEqual(error, .busy(.heartRateMeasurement))
}
try await arbiter.release(.heartRateMeasurement)
try await arbiter.acquire(.hapticRegulation)
}
@MainActor
func testStoreCapsCanonicalHistoryButKeepsOutbox() throws {
let suite = "HaptikaWatchTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = WatchStore(defaults: defaults)
for index in 0..<35 {
let value = measurement(index: index, bpm: 70)
let envelope = try WatchSyncEnvelopeV1(deviceID: device.id, sequence: UInt64(index), kind: .measurementUpsert, payload: value)
store.upsert(value, enqueue: envelope)
}
XCTAssertEqual(store.measurements.count, 30)
XCTAssertEqual(store.outbox.count, 35)
}
private func measurement(index: Int, bpm: Double) -> WatchMeasurementSummaryV1 {
let start = Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 100))
let samples = (0..<8).map { sampleIndex in
WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(sampleIndex) * 7), bpm: bpm)
}
return .init(
startedAt: start,
endedAt: start.addingTimeInterval(60),
device: device,
currentBPM: bpm,
averageBPM: bpm,
minimumBPM: bpm,
maximumBPM: bpm,
samples: samples,
coverageSeconds: 49,
quality: 0.82,
isValid: true,
context: .init(posture: .seated)
)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ require Pod::Executable.execute_command('node', ['-p',
{paths: [process.argv[1]]}, {paths: [process.argv[1]]},
)', __dir__]).strip )', __dir__]).strip
platform :ios, min_ios_version_supported platform :ios, '16.0'
prepare_react_native_project! prepare_react_native_project!
linkage = ENV['USE_FRAMEWORKS'] linkage = ENV['USE_FRAMEWORKS']
+2455
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
module.exports = { module.exports = {
preset: '@react-native/jest-preset', preset: '@react-native/jest-preset',
watchman: false,
}; };
+8 -1
View File
@@ -6,6 +6,13 @@ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
* *
* @type {import('@react-native/metro-config').MetroConfig} * @type {import('@react-native/metro-config').MetroConfig}
*/ */
const config = {}; const config = {
// The node crawler is more reliable than Watchman on workspaces stored in
// synchronised/user-document folders, where Watchman can deadlock while
// resolving the project root.
resolver: {
useWatchman: false,
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config); module.exports = mergeConfig(getDefaultConfig(__dirname), config);
+42
View File
@@ -0,0 +1,42 @@
# Design QA — onboarding Haptika
## Source visual truth
- Figma file: `bzR4w4mYdTfM7DojW8n9g7`
- Frames: `109:320`, `109:349`, `109:423`, `109:526`, `109:583`, `109:650`, `109:741`
- Natural Figma viewport: 402 × 874
- The seven frames and the 337 × 369 silhouette asset were captured through the local Figma Desktop MCP before its access expired.
## Rendered implementation
- Device: iPhone 17 Pro Max simulator, iOS 26.5
- Captured viewport: 368 × 800 optimized from the simulator render
- State: fresh install, full seven-step journey, completion, then relaunch with persisted completion
- Evidence:
- `qa/onboarding/step-1-welcome.jpg`
- `qa/onboarding/step-3-assessment.jpg`
- `qa/onboarding/step-6-experience.jpg`
- `qa/onboarding/step-7-complete.jpg`
## Fidelity review
- Fonts and typography: system SF typography, light display headings, compact labels and cyan emphasis match the reference hierarchy. No clipping was observed.
- Spacing and layout rhythm: 20-point side margins, 56-point actions, 14-point radii and the seven-step rhythm match the measured frames. The layout expands proportionally on the 440-point Pro Max canvas.
- Colors and tokens: near-black navy canvas, blue-black cards, thin blue borders, muted gray copy and cyan active states match the Figma palette.
- Image quality: the welcome silhouettes are the original Figma node export, not a recreation. Standard UI symbols use the Phosphor React Native icon library.
- Copy and content: the seven Figma sections are present. “Précision clinique” was intentionally replaced by a non-medical claim.
- Interaction: continue actions, assessments, Apple Health choice, profile text input, year modal, experience choice, completion and persistence were exercised in the simulator.
## Comparison history
1. Initial Pro Max render used the fixed 402-point frame width, producing excessive side margins. Fixed by allowing the layout to expand to 440 points while retaining the Figma 20-point gutters.
2. Simulator automation exposed duplicate fast taps skipping steps. Fixed with a 450 ms transition lock and retested from a clean install.
3. All seven screens fit without horizontal overflow or hidden primary actions. No visible P0/P1/P2 issue remains in the captured implementation states.
## Blocking evidence gap
The Figma Desktop MCP stopped granting access after the implementation captures were taken and now asks for a paid Dev Mode seat/file permission check. A fresh source PNG could therefore not be saved beside the implementation screenshots, so the required combined side-by-side comparison input cannot be produced in this run.
Focused comparison was not completed for the same reason. The individual source frames and implementation captures were inspected, but the formal combined-input gate remains unavailable.
final result: blocked
+12681
View File
File diff suppressed because it is too large Load Diff
+20 -7
View File
@@ -3,27 +3,39 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios", "ios": "react-native run-ios",
"ios:pods": "cd ios && bundle exec pod install",
"lint": "eslint .", "lint": "eslint .",
"native:specs": "cd packages/react-native-haptika-ppg && npx nitrogen .",
"start": "react-native start", "start": "react-native start",
"test": "jest" "test": "jest",
"test:ci": "jest --runInBand",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "3.1.1",
"phosphor-react-native": "^3.0.6",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
"@react-native/new-app-screen": "0.86.0", "react-native-haptika-ppg": "file:packages/react-native-haptika-ppg",
"react-native-safe-area-context": "^5.5.2" "react-native-nitro-image": "0.15.1",
"react-native-nitro-modules": "0.36.1",
"react-native-safe-area-context": "5.5.2",
"react-native-svg": "^15.15.5",
"react-native-vision-camera": "5.1.0",
"react-native-vision-camera-worklets": "5.1.0",
"react-native-worklets": "0.10.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3", "@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0", "@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.1.0", "@react-native-community/cli": "^20.2.0",
"@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-android": "^20.2.0",
"@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-platform-ios": "^20.2.0",
"@react-native/babel-preset": "0.86.0", "@react-native/babel-preset": "0.86.0",
"@react-native/eslint-config": "0.86.0", "@react-native/eslint-config": "0.86.0",
"@react-native/jest-preset": "^0.86.0",
"@react-native/metro-config": "0.86.0", "@react-native/metro-config": "0.86.0",
"@react-native/typescript-config": "0.86.0", "@react-native/typescript-config": "0.86.0",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
@@ -31,6 +43,7 @@
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"jest": "^29.6.3", "jest": "^29.6.3",
"nitrogen": "0.36.1",
"prettier": "2.8.8", "prettier": "2.8.8",
"react-test-renderer": "19.2.3", "react-test-renderer": "19.2.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"
@@ -0,0 +1,30 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
Pod::Spec.new do |s|
s.name = "HaptikaPPG"
s.version = package["version"]
s.summary = package["description"]
s.homepage = "https://haptika.local"
s.license = { :type => "Proprietary" }
s.authors = { "Haptika" => "engineering@haptika.local" }
s.platforms = { :ios => "16.0" }
s.source = { :path => "." }
# Nitro Modules 0.36.1 is not yet clean under Swift 6 strict concurrency.
s.swift_version = "5.0"
s.source_files = [
"ios/**/*.{swift,m,mm,cpp,h,hpp}",
"cpp/**/*.{cpp,h,hpp}"
]
load "nitrogen/generated/ios/HaptikaPPG+autolinking.rb"
add_nitrogen_files(s)
s.frameworks = "AVFoundation", "Accelerate", "CoreVideo", "HealthKit"
s.dependency "VisionCamera"
s.dependency "React-jsi"
s.dependency "React-callinvoker"
install_modules_dependencies(s)
end
@@ -0,0 +1,47 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "HaptikaPPGNativeTests",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
targets: [
.target(
name: "HaptikaFrameMetrics",
path: "ios/FrameProcessor",
exclude: [
"HybridHaptikaPPGFrameProcessor.swift",
"PPGFingerContactDetector.swift",
],
sources: ["PPGFrameMetricsExtractor.swift"]
),
.target(
name: "HaptikaSignal",
path: "ios/Signal"
),
.target(
name: "HaptikaSafety",
path: "ios/Safety",
exclude: ["PPGThermalMonitor.swift"],
sources: ["PPGThermalSafetyPolicy.swift"]
),
.testTarget(
name: "HaptikaFrameMetricsTests",
dependencies: ["HaptikaFrameMetrics"],
path: "Tests/HaptikaFrameMetricsTests"
),
.testTarget(
name: "HaptikaSignalTests",
dependencies: ["HaptikaSignal"],
path: "Tests/HaptikaSignalTests"
),
.testTarget(
name: "HaptikaSafetyTests",
dependencies: ["HaptikaSafety"],
path: "Tests/HaptikaSafetyTests"
),
]
)
@@ -0,0 +1,341 @@
import CoreMedia
import CoreVideo
import XCTest
@testable import HaptikaFrameMetrics
final class PPGFrameMetricsExtractorTests: XCTestCase {
func testFullRangeYUVExtractionUsesCentralROIAndUnlocksBuffer() throws {
let pixelBuffer = try makePixelBuffer(
width: 64,
height: 64,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
)
try fillBiPlanarYUV(pixelBuffer, y: 100, cb: 128, cr: 128)
let metrics = try PPGFrameMetricsExtractor(gridSize: 16, roiFraction: 0.5)
.extract(from: makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 4.25))
XCTAssertEqual(metrics.timestampSeconds, 4.25, accuracy: 0.000_001)
XCTAssertEqual(metrics.redMean, 100, accuracy: 0.000_001)
XCTAssertEqual(metrics.greenMean, 100, accuracy: 0.000_001)
XCTAssertEqual(metrics.blueMean, 100, accuracy: 0.000_001)
XCTAssertEqual(metrics.luminanceMean, 100, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testVideoRangeYUVExtractionAppliesVideoRangeConversion() throws {
let pixelBuffer = try makePixelBuffer(
width: 48,
height: 48,
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
)
try fillBiPlanarYUV(pixelBuffer, y: 126, cb: 128, cr: 128)
let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5)
.extract(from: makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 2))
let expected = 1.164_383 * 110
XCTAssertEqual(metrics.redMean, expected, accuracy: 0.001)
XCTAssertEqual(metrics.greenMean, expected, accuracy: 0.001)
XCTAssertEqual(metrics.blueMean, expected, accuracy: 0.001)
try assertCanRelock(pixelBuffer)
}
func testBGRAExtractionSamplesOnlyCentralROI() throws {
let pixelBuffer = try makePixelBuffer(
width: 64,
height: 64,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { x, y in
if (16..<48).contains(x), (16..<48).contains(y) {
return (blue: 20, green: 80, red: 200, alpha: 255)
}
return (blue: 240, green: 10, red: 5, alpha: 255)
}
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 12.5)
let metrics = try PPGFrameMetricsExtractor(gridSize: 16, roiFraction: 0.5)
.extract(from: sampleBuffer)
XCTAssertEqual(metrics.timestampSeconds, 12.5, accuracy: 0.000_001)
XCTAssertEqual(metrics.redMean, 200, accuracy: 0.000_001)
XCTAssertEqual(metrics.greenMean, 80, accuracy: 0.000_001)
XCTAssertEqual(metrics.blueMean, 20, accuracy: 0.000_001)
XCTAssertEqual(metrics.redVariance, 0, accuracy: 0.000_001)
XCTAssertEqual(metrics.greenVariance, 0, accuracy: 0.000_001)
XCTAssertEqual(
metrics.luminanceMean,
0.2126 * 200 + 0.7152 * 80 + 0.0722 * 20,
accuracy: 0.000_001
)
XCTAssertEqual(metrics.saturationRatio, 0, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testBGRARedOnlyClippingKeepsUsableGreenChannel() throws {
let pixelBuffer = try makePixelBuffer(
width: 48,
height: 48,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { x, y in
let isCentral = (12..<36).contains(x) && (12..<36).contains(y)
return isCentral
? (blue: 40, green: 100, red: 255, alpha: 255)
: (blue: 0, green: 0, red: 0, alpha: 255)
}
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 1)
let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5)
.extract(from: sampleBuffer)
XCTAssertEqual(metrics.redMean, 255, accuracy: 0.000_001)
XCTAssertEqual(metrics.saturationRatio, 0, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testBGRASaturationRequiresBothUsefulChannelsToClip() throws {
let pixelBuffer = try makePixelBuffer(
width: 48,
height: 48,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { _, _ in
(blue: 80, green: 255, red: 255, alpha: 255)
}
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 1)
let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5)
.extract(from: sampleBuffer)
XCTAssertEqual(metrics.saturationRatio, 1, accuracy: 0.000_001)
try assertCanRelock(pixelBuffer)
}
func testUnsupportedPixelFormatUnlocksBufferOnThrow() throws {
let pixelBuffer = try makePixelBuffer(
width: 32,
height: 32,
pixelFormat: kCVPixelFormatType_OneComponent8
)
let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 2)
XCTAssertThrowsError(try PPGFrameMetricsExtractor().extract(from: sampleBuffer)) { error in
guard case PPGFrameMetricsExtractorError.unsupportedPixelFormat(let format) = error else {
return XCTFail("Expected unsupportedPixelFormat, got \(error)")
}
XCTAssertEqual(format, kCVPixelFormatType_OneComponent8)
}
try assertCanRelock(pixelBuffer)
}
func testRepeatedExtractionDoesNotLeavePixelBuffersLocked() throws {
let extractor = PPGFrameMetricsExtractor(gridSize: 8, roiFraction: 0.5)
for index in 0..<100 {
autoreleasepool {
do {
let pixelBuffer = try makePixelBuffer(
width: 32,
height: 32,
pixelFormat: kCVPixelFormatType_32BGRA
)
try fillBGRA(pixelBuffer) { _, _ in (20, 90, 180, 255) }
_ = try extractor.extract(
from: makeSampleBuffer(
pixelBuffer: pixelBuffer,
timestampSeconds: Double(index) / 30
)
)
try assertCanRelock(pixelBuffer)
} catch {
XCTFail("Repeated extraction failed at \(index): \(error)")
}
}
}
}
private func makePixelBuffer(
width: Int,
height: Int,
pixelFormat: OSType
) throws -> CVPixelBuffer {
if pixelFormat == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|| pixelFormat == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange {
return try makeBiPlanarPixelBuffer(width: width, height: height, pixelFormat: pixelFormat)
}
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferCreate(
kCFAllocatorDefault,
width,
height,
pixelFormat,
nil,
&pixelBuffer
)
guard status == kCVReturnSuccess, let pixelBuffer else {
throw TestBufferError.pixelBufferCreation(status)
}
return pixelBuffer
}
private func makeBiPlanarPixelBuffer(
width: Int,
height: Int,
pixelFormat: OSType
) throws -> CVPixelBuffer {
let yBytesPerRow = width
let uvBytesPerRow = width
let ySize = yBytesPerRow * height
let uvHeight = height / 2
let totalSize = ySize + uvBytesPerRow * uvHeight
guard let storage = malloc(totalSize) else {
throw TestBufferError.missingBaseAddress
}
var planeAddresses: [UnsafeMutableRawPointer?] = [
storage,
storage.advanced(by: ySize),
]
var planeWidths = [width, width / 2]
var planeHeights = [height, uvHeight]
var planeBytesPerRow = [yBytesPerRow, uvBytesPerRow]
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferCreateWithPlanarBytes(
kCFAllocatorDefault,
width,
height,
pixelFormat,
storage,
totalSize,
2,
&planeAddresses,
&planeWidths,
&planeHeights,
&planeBytesPerRow,
{ _, dataPointer, _, _, _ in
free(UnsafeMutableRawPointer(mutating: dataPointer))
},
nil,
nil,
&pixelBuffer
)
guard status == kCVReturnSuccess, let pixelBuffer else {
free(storage)
throw TestBufferError.pixelBufferCreation(status)
}
return pixelBuffer
}
private func fillBiPlanarYUV(
_ pixelBuffer: CVPixelBuffer,
y: UInt8,
cb: UInt8,
cr: UInt8
) throws {
let status = CVPixelBufferLockBaseAddress(pixelBuffer, [])
guard status == kCVReturnSuccess else {
throw TestBufferError.pixelBufferLock(status)
}
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2,
let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
else {
throw TestBufferError.missingBaseAddress
}
let yHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
let yBytes = yBase.assumingMemoryBound(to: UInt8.self)
for row in 0..<yHeight {
yBytes.advanced(by: row * yStride).initialize(repeating: y, count: yStride)
}
let uvWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 1)
let uvHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 1)
let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self)
for row in 0..<uvHeight {
for column in 0..<uvWidth {
let offset = row * uvStride + column * 2
uvBytes[offset] = cb
uvBytes[offset + 1] = cr
}
}
}
private func fillBGRA(
_ pixelBuffer: CVPixelBuffer,
pixel: (_ x: Int, _ y: Int) -> (blue: UInt8, green: UInt8, red: UInt8, alpha: UInt8)
) throws {
let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, [])
guard lockStatus == kCVReturnSuccess else {
throw TestBufferError.pixelBufferLock(lockStatus)
}
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
throw TestBufferError.missingBaseAddress
}
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
for y in 0..<height {
for x in 0..<width {
let color = pixel(x, y)
let offset = y * bytesPerRow + x * 4
bytes[offset] = color.blue
bytes[offset + 1] = color.green
bytes[offset + 2] = color.red
bytes[offset + 3] = color.alpha
}
}
}
private func makeSampleBuffer(
pixelBuffer: CVPixelBuffer,
timestampSeconds: Double
) throws -> CMSampleBuffer {
var formatDescription: CMVideoFormatDescription?
let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
)
guard formatStatus == noErr, let formatDescription else {
throw TestBufferError.formatDescriptionCreation(formatStatus)
}
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: CMTime(seconds: timestampSeconds, preferredTimescale: 60_000),
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
let sampleStatus = CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
)
guard sampleStatus == noErr, let sampleBuffer else {
throw TestBufferError.sampleBufferCreation(sampleStatus)
}
return sampleBuffer
}
private func assertCanRelock(_ pixelBuffer: CVPixelBuffer) throws {
let status = CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
guard status == kCVReturnSuccess else {
throw TestBufferError.pixelBufferLock(status)
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly)
}
}
private enum TestBufferError: Error {
case pixelBufferCreation(CVReturn)
case pixelBufferLock(CVReturn)
case missingBaseAddress
case formatDescriptionCreation(OSStatus)
case sampleBufferCreation(OSStatus)
}
@@ -0,0 +1,16 @@
import XCTest
@testable import HaptikaSafety
final class PPGThermalSafetyPolicyTests: XCTestCase {
func testSeriousAndCriticalStatesStopMeasurement() {
XCTAssertTrue(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "serious"))
XCTAssertTrue(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "critical"))
}
func testNominalFairAndUnknownStatesRemainAvailable() {
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "nominal"))
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "fair"))
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "unknown"))
XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "future-state"))
}
}
@@ -0,0 +1,52 @@
import XCTest
@testable import HaptikaSignal
final class PPGCoherencePolicyTests: XCTestCase {
func testSessionDurationLimitsRemainPurposeSpecific() {
XCTAssertEqual(PPGCoherencePolicy.maximumDurationSeconds(isCoherence: false), 120)
XCTAssertEqual(PPGCoherencePolicy.maximumDurationSeconds(isCoherence: true), 300)
XCTAssertEqual(PPGCoherencePolicy.coherenceBufferDurationSeconds, 75)
}
func testLiveTrackerPublishesEveryNewBeatOnlyOnce() {
var tracker = PPGLiveBeatTracker()
let first = tracker.ingest([
interval(end: 1, duration: 800), interval(end: 1.8, duration: 800),
])
XCTAssertEqual(first.map(\.sequence), [1, 2])
let overlapping = tracker.ingest([
interval(end: 1.82, duration: 800), interval(end: 2.6, duration: 780),
])
XCTAssertEqual(overlapping.map(\.sequence), [3])
XCTAssertEqual(overlapping.first?.timestampSeconds, 2.6)
}
func testTrackerDoesNotMoveBackwardAndResetsCleanly() {
var tracker = PPGLiveBeatTracker()
_ = tracker.ingest([interval(end: 10, duration: 700)])
XCTAssertTrue(tracker.ingest([interval(end: 9.9, duration: 700)]).isEmpty)
tracker.reset()
XCTAssertEqual(tracker.ingest([interval(end: 1, duration: 700)]).first?.sequence, 1)
}
func testTrackerRejectsInvalidAndRejectedIntervals() {
var tracker = PPGLiveBeatTracker()
let rejected = PulseInterval(
sourceIndex: 0, startSeconds: 0, endSeconds: 1,
durationMilliseconds: 800, rejectionReason: .localOutlier, qualityScore: 1
)
XCTAssertTrue(tracker.ingest([
rejected, interval(end: .nan, duration: 800), interval(end: 2, duration: 2_500),
]).isEmpty)
}
private func interval(end: Double, duration: Double) -> PulseInterval {
PulseInterval(
sourceIndex: 0,
startSeconds: end - duration / 1_000,
endSeconds: end,
durationMilliseconds: duration,
qualityScore: 0.9
)
}
}
@@ -0,0 +1,247 @@
import XCTest
@testable import HaptikaSignal
final class PPGSignalPipelineTests: XCTestCase {
func testPRVCalculatorDerivesIntervalMetrics() throws {
let intervals = [700.0, 720, 760, 710, 800, 730, 750, 705]
let metrics = try PPGPRVCalculator.calculate(
intervalsMilliseconds: intervals,
configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 5)
)
XCTAssertEqual(metrics.meanIpiMs ?? 0, 734.375, accuracy: 0.001)
XCTAssertEqual(metrics.medianIpiMs ?? 0, 725, accuracy: 0.001)
XCTAssertEqual(metrics.pnn50Percent ?? 0, 200.0 / 7.0, accuracy: 0.001)
XCTAssertEqual(metrics.rangeIpiMs ?? 0, 100, accuracy: 0.001)
XCTAssertEqual(metrics.modeIpiMs ?? 0, 713, accuracy: 0.001)
XCTAssertEqual(metrics.amo50Percent ?? 0, 62.5, accuracy: 0.001)
XCTAssertEqual((metrics.coefficientOfVariation ?? 0) * 100, 4.68, accuracy: 0.1)
}
func testBPMEstimatorCorrectsSecondHarmonic() throws {
let sampleRate = 30.0
let duration = 30.0
let sampleCount = Int(sampleRate * duration)
let timestamps = (0..<sampleCount).map { Double($0) / sampleRate }
// The spectral method sees a strong 144 BPM harmonic while the clean peak
// intervals correctly describe a 72 BPM pulse.
let signal = timestamps.map { sin(2 * Double.pi * 2.4 * $0) }
let peakInterval = 60.0 / 72.0
let peaks = (0..<35).map { index in
DetectedPeak(
sampleIndex: Int((Double(index) * peakInterval * sampleRate).rounded()),
timestampSeconds: Double(index) * peakInterval,
amplitude: 1,
prominence: 1
)
}
let intervals = zip(peaks, peaks.dropFirst()).enumerated().map { index, pair in
PulseInterval(
sourceIndex: index,
startSeconds: pair.0.timestampSeconds,
endSeconds: pair.1.timestampSeconds,
durationMilliseconds: peakInterval * 1_000
)
}
let detection = PeakDetectionResult(
peaks: peaks,
intervals: intervals,
selectedPolarity: 1,
confidence: 0.9
)
let estimate = try PPGBPMEstimator.estimate(
signal: signal,
timestampsSeconds: timestamps,
validityMask: Array(repeating: true, count: sampleCount),
peakDetection: detection,
selectedChannel: .greenRedRatio,
configuration: .production
)
XCTAssertNotNil(estimate.bpm)
XCTAssertEqual(estimate.bpm ?? 0, 72, accuracy: 1.5)
XCTAssertEqual(estimate.frequencyBpm ?? 0, 72, accuracy: 1.5)
}
func testProcessorProducesHeartRateFromCleanContactPPG() throws {
let sampleRate = 30.0
let duration = 55.0
let sampleCount = Int(sampleRate * duration) + 1
let samples = (0..<sampleCount).map { index -> PPGFrameSample in
let time = Double(index) / sampleRate
let pulse = sin(2 * Double.pi * 1.2 * time)
+ 0.18 * sin(2 * Double.pi * 2.4 * time)
let slowLightDrift = 1 + 0.08 * sin(2 * Double.pi * 0.08 * time)
return PPGFrameSample(
timestampSeconds: time,
red: 185 * slowLightDrift + 2.5 * pulse,
green: 82 * slowLightDrift + 5.5 * pulse,
luminance: 112 * slowLightDrift + 4 * pulse,
contactConfidence: 0.9,
saturationRatio: 0,
motionArtifactScore: 0.02
)
}
let configuration = PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
let result = try PPGSignalProcessor(configuration: configuration)
.analyze(samples: samples)
XCTAssertEqual(result.status, .valid)
XCTAssertNotNil(result.bpm.bpm)
XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3)
XCTAssertGreaterThan(result.quality.score, 0.65)
}
func testResamplerKeepsUniformGridAndMasksLongGap() throws {
let input = [
TimestampedValue(timestampSeconds: 0.00, value: 0),
TimestampedValue(timestampSeconds: 0.09, value: 1),
TimestampedValue(timestampSeconds: 0.21, value: 2),
TimestampedValue(timestampSeconds: 0.80, value: 3),
TimestampedValue(timestampSeconds: 0.91, value: 4),
]
let result = try PPGSignalResampler.resample(
input,
targetSampleRateHz: 10,
maximumInterpolationGapSeconds: 0.20
)
XCTAssertEqual(result.timestampsSeconds.count, result.values.count)
XCTAssertEqual(result.timestampsSeconds[1] - result.timestampsSeconds[0], 0.1, accuracy: 1e-9)
XCTAssertFalse(result.validityMask[4], "A long camera gap must remain unusable")
XCTAssertGreaterThan(result.diagnostics.droppedFrameRatio, 0)
}
func testPRVLocalFilterRejectsArtifactWithoutRejectingGradualChange() throws {
let intervals = [700.0, 710, 720, 730, 740, 400, 760, 770, 780, 790, 800]
let result = try PPGPRVCalculator.calculate(
intervals: makeIntervals(intervals),
configuration: PPGProcessingConfiguration(
minimumIntervalsForPRV: 5,
maximumRejectedIntervalRatioForPRV: 0.25
)
)
XCTAssertEqual(result.intervals[5].rejectionReason, .localOutlier)
XCTAssertTrue(result.intervals[10].isAccepted, "A gradual HR change must remain usable")
XCTAssertEqual(result.metrics.validIntervalCount, intervals.count - 1)
XCTAssertTrue(result.metrics.reliable)
}
func testProcessorCanRecoverPulseCarriedByBlueChrominance() throws {
let sampleRate = 30.0
let duration = 55.0
let samples = (0...Int(sampleRate * duration)).map { index -> PPGFrameSample in
let time = Double(index) / sampleRate
let pulse = sin(2 * Double.pi * 1.2 * time)
+ 0.20 * sin(2 * Double.pi * 2.4 * time)
let red = 150.0
let green = 100.0
let blue = 80 + 9 * pulse
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return PPGFrameSample(
timestampSeconds: time,
red: red,
green: green,
luminance: luminance,
contactConfidence: 0.95,
saturationRatio: 0,
motionArtifactScore: 0
)
}
let result = try PPGSignalProcessor(
configuration: PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
).analyze(samples: samples)
XCTAssertEqual(result.status, .valid)
XCTAssertTrue(
[.blue, .greenRedRatio, .blueRedRatio, .normalizedBlend]
.contains(result.bpm.selectedChannel)
)
XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3)
}
func testCoachingContactScoreDoesNotDiscardARealPeriodicSignal() throws {
let sampleRate = 30.0
let duration = 55.0
let samples = (0...Int(sampleRate * duration)).map { index -> PPGFrameSample in
let time = Double(index) / sampleRate
let pulse = sin(2 * Double.pi * 1.25 * time)
+ 0.16 * sin(2 * Double.pi * 2.5 * time)
return PPGFrameSample(
timestampSeconds: time,
red: 142 + 3.2 * pulse,
green: 104 + 5.8 * pulse,
blue: 76 + 1.4 * pulse,
luminance: 111 + 4.9 * pulse,
contactConfidence: 0.10,
saturationRatio: 0,
motionArtifactScore: 0.02
)
}
let result = try PPGSignalProcessor(
configuration: PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
).analyze(samples: samples)
XCTAssertEqual(result.status, .valid)
XCTAssertEqual(result.bpm.bpm ?? 0, 75, accuracy: 3)
XCTAssertTrue(result.quality.issues.contains(.poorFingerContact))
}
func testBeatMorphologyScoresDistortedCycleWithoutLosingCleanBeats() throws {
let sampleRate = 30.0
let periodSamples = 25
let sampleCount = periodSamples * 14 + 1
var signal = (0..<sampleCount).map { index in
cos(2 * Double.pi * Double(index % periodSamples) / Double(periodSamples))
}
// Preserve the two bounding pulse peaks but flatten one complete cycle.
for index in (periodSamples * 6 + 1)..<(periodSamples * 7) {
signal[index] = -0.15
}
let timestamps = (0..<sampleCount).map { Double($0) / sampleRate }
let result = try PPGPeakDetector.detect(
signal: signal,
timestampsSeconds: timestamps,
validityMask: Array(repeating: true, count: sampleCount),
configuration: PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 1,
minimumPeakProminence: 0.10,
minimumPeakAmplitude: 0.05
)
)
let scores = result.intervals.map(\.qualityScore)
XCTAssertLessThan(
scores.min() ?? 1,
SignalStatistics.median(scores) - 0.05,
"The flattened pulse must receive a distinctly lower technical quality"
)
XCTAssertGreaterThan(result.acceptedIntervals.count, 8)
}
private func makeIntervals(_ durations: [Double]) -> [PulseInterval] {
var elapsed = 0.0
return durations.enumerated().map { index, duration in
defer { elapsed += duration / 1_000 }
return PulseInterval(
sourceIndex: index,
startSeconds: elapsed,
endSeconds: elapsed + duration / 1_000,
durationMilliseconds: duration
)
}
}
}
@@ -0,0 +1,202 @@
import XCTest
@testable import HaptikaSignal
final class PPGSyntheticSignalTests: XCTestCase {
private let processorConfiguration = PPGProcessingConfiguration(
minimumAnalysisDurationSeconds: 45,
filterTapCount: 61
)
func testPublicationThresholdsRemainOrderedByScientificDemand() {
XCTAssertEqual(PPGPublicationThresholds.bpmQualityScore, 0.45)
XCTAssertEqual(PPGPublicationThresholds.potentialIndexQualityScore, 0.65)
XCTAssertEqual(PPGPublicationThresholds.prvQualityScore, 0.80)
XCTAssertLessThan(
PPGPublicationThresholds.bpmQualityScore,
PPGPublicationThresholds.potentialIndexQualityScore
)
XCTAssertLessThan(
PPGPublicationThresholds.potentialIndexQualityScore,
PPGPublicationThresholds.prvQualityScore
)
}
func testGeneratorIsDeterministicAndAppliesAllConfiguredArtifacts() throws {
let configuration = PPGSyntheticSignalConfiguration(
durationSeconds: 10,
startingBPM: 72,
intervalVariationMilliseconds: [-20, 20],
noiseAmplitude: 1.5,
driftAmplitude: 0.05,
movementWindows: [.init(startSeconds: 2, endSeconds: 3, intensity: 0.8)],
saturationWindows: [.init(startSeconds: 5, endSeconds: 6)],
dropEveryNthFrame: 10,
missingBeatIndices: [3],
falsePeakTimesSeconds: [7.25],
seed: 42
)
let first = try PPGSyntheticSignalGenerator.generate(configuration: configuration)
let second = try PPGSyntheticSignalGenerator.generate(configuration: configuration)
XCTAssertEqual(first, second)
XCTAssertLessThan(first.samples.count, 301)
XCTAssertTrue(first.samples.contains { $0.motionArtifactScore >= 0.8 })
XCTAssertTrue(first.samples.contains { $0.saturationRatio == 1 })
XCTAssertEqual(first.nominalIntervalsMilliseconds[0], 60_000 / 72 - 20, accuracy: 0.001)
XCTAssertEqual(first.nominalIntervalsMilliseconds[1], 60_000 / 72 + 20, accuracy: 0.001)
}
func testEndToEndCleanSignalsAt60_75And120BPM() throws {
let processor = try PPGSignalProcessor(configuration: processorConfiguration)
for expectedBPM in [60.0, 75.0, 120.0] {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: expectedBPM,
noiseAmplitude: 0.25,
driftAmplitude: 0.04,
seed: UInt64(expectedBPM)
)
)
let result = try processor.analyze(samples: fixture.samples)
XCTAssertEqual(result.status, .valid, "Expected a valid \(expectedBPM) BPM fixture")
XCTAssertEqual(result.bpm.bpm ?? 0, expectedBPM, accuracy: 3)
XCTAssertFalse(result.quality.issues.contains(.nonFiniteInput))
}
}
func testModerateNoiseDriftAndDroppedFramesRemainAnalyzable() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 78,
noiseAmplitude: 1.2,
driftAmplitude: 0.10,
dropEveryNthFrame: 20,
seed: 77
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertEqual(result.bpm.bpm ?? 0, 78, accuracy: 4)
XCTAssertGreaterThan(result.timebase.droppedFrameRatio, 0)
}
func testMovementAndSaturationAreReportedInsteadOfProducingSilentPRV() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 72,
noiseAmplitude: 0.5,
movementWindows: [.init(startSeconds: 10, endSeconds: 45, intensity: 1)],
saturationWindows: [.init(startSeconds: 8, endSeconds: 52, intensity: 1)],
seed: 91
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertTrue(result.quality.issues.contains(.motionArtifacts))
XCTAssertTrue(result.quality.issues.contains(.excessiveSaturation))
XCTAssertFalse(result.prv.reliable)
}
func testMissingAndFalsePeaksDoNotCreateNonFiniteMetrics() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 75,
noiseAmplitude: 0.4,
missingBeatIndices: [8, 22, 41],
falsePeakTimesSeconds: [12.30, 31.15, 46.40],
seed: 121
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertTrue(result.bpm.confidence.isFinite)
XCTAssertTrue(result.quality.score.isFinite)
XCTAssertTrue(result.intervals.allSatisfy { $0.durationMilliseconds.isFinite })
XCTAssertGreaterThan(result.bpm.rejectedPeakCount + result.prv.rejectedIntervalCount, 0)
}
func testProgressiveBPMChangeStaysInsideGeneratedRange() throws {
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 60,
endingBPM: 90,
noiseAmplitude: 0.2,
seed: 3
)
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertNotNil(result.bpm.bpm)
XCTAssertTrue((60...90).contains(result.bpm.bpm ?? 0))
XCTAssertGreaterThan(fixture.nominalIntervalsMilliseconds.first ?? 0, 950)
XCTAssertLessThan(fixture.nominalIntervalsMilliseconds.last ?? 1_000, 700)
}
func testKnownIntervalVariabilityProducesExpectedRMSSD() throws {
let offsets = [-30.0, 30.0]
let fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(
durationSeconds: 60,
startingBPM: 75,
intervalVariationMilliseconds: offsets
)
)
let metrics = try PPGPRVCalculator.calculate(
intervalsMilliseconds: Array(fixture.nominalIntervalsMilliseconds.prefix(60)),
configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 20)
)
XCTAssertEqual(metrics.estimatedRmssdMs ?? 0, 60, accuracy: 0.001)
XCTAssertTrue(metrics.reliable)
}
func testNaNAndInfinityFramesAreDiscardedExhaustively() throws {
var fixture = try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(durationSeconds: 60, startingBPM: 72)
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: .nan, red: 1, green: 1, luminance: 1),
at: 20
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: 2, red: .infinity, green: 1, luminance: 1),
at: 40
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: 3, red: 1, green: -.infinity, luminance: 1),
at: 60
)
fixture.samples.insert(
PPGFrameSample(timestampSeconds: 4, red: 1, green: 1, luminance: .nan),
at: 80
)
let result = try PPGSignalProcessor(configuration: processorConfiguration)
.analyze(samples: fixture.samples)
XCTAssertEqual(result.timebase.discardedSampleCount, 4)
XCTAssertTrue(result.quality.issues.contains(.nonFiniteInput))
XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3)
}
func testGeneratorRejectsUnsafeConfiguration() {
XCTAssertThrowsError(
try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(sampleRateHz: .nan)
)
)
XCTAssertThrowsError(
try PPGSyntheticSignalGenerator.generate(
configuration: PPGSyntheticSignalConfiguration(dropEveryNthFrame: 1)
)
)
}
}
@@ -0,0 +1,887 @@
import CoreMedia
import Foundation
import NitroModules
import VisionCamera
/// Nitro entry point for camera PPG processing.
///
/// A `HybridFrameSpec` and its `CMSampleBuffer` exist only as local values during
/// `processFrame(frame:)`. Persistent state contains compact numeric metrics only.
final class HybridHaptikaPPGFrameProcessor: HybridHaptikaPPGFrameProcessorSpec {
private enum SessionPhase {
case idle
case active
case thermalStopped
case finishing
}
private struct MeasurementConfiguration: Sendable {
let sessionPurpose: PPGSessionPurpose
let targetSampleRate: Double
let minimumFrequencyHz: Double
let maximumFrequencyHz: Double
let stabilizationSeconds: Double
let measurementSeconds: Double
let emitEveryFrames: Int
let minimumContactConfidence: Double
let maximumInterpolationGapSeconds: Double
let longGapLimitSeconds: Double
let maximumBufferedSampleCount: Int
init(native: NativePPGConfiguration) throws {
guard native.targetSampleRate.isFinite,
native.targetSampleRate >= 20,
native.targetSampleRate <= 120
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"targetSampleRate must be between 20 and 120 Hz"
)
}
guard native.minimumFrequencyHz.isFinite,
native.maximumFrequencyHz.isFinite,
native.minimumFrequencyHz > 0,
native.maximumFrequencyHz > native.minimumFrequencyHz,
native.maximumFrequencyHz < native.targetSampleRate / 2
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"the physiological band must be ordered and below Nyquist"
)
}
guard native.stabilizationSeconds.isFinite,
native.stabilizationSeconds >= 0,
native.stabilizationSeconds <= 30
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"stabilizationSeconds must be between 0 and 30"
)
}
let maximumDuration = PPGCoherencePolicy.maximumDurationSeconds(
isCoherence: native.sessionPurpose == .coherence
)
guard native.measurementSeconds.isFinite,
native.measurementSeconds >= 20,
native.measurementSeconds <= maximumDuration
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"measurementSeconds exceeds the limit for this session purpose"
)
}
guard native.emitEveryFrames.isFinite,
native.emitEveryFrames >= 1,
native.emitEveryFrames.rounded() == native.emitEveryFrames,
native.emitEveryFrames <= 10_000
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"emitEveryFrames must be a positive integer"
)
}
guard native.minimumContactConfidence.isFinite,
(0...1).contains(native.minimumContactConfidence)
else {
throw HaptikaPPGFrameProcessorError.invalidConfiguration(
"minimumContactConfidence must be between 0 and 1"
)
}
sessionPurpose = native.sessionPurpose
targetSampleRate = native.targetSampleRate
minimumFrequencyHz = native.minimumFrequencyHz
maximumFrequencyHz = native.maximumFrequencyHz
stabilizationSeconds = native.stabilizationSeconds
measurementSeconds = native.measurementSeconds
emitEveryFrames = Int(native.emitEveryFrames)
minimumContactConfidence = native.minimumContactConfidence
maximumInterpolationGapSeconds = 0.25
longGapLimitSeconds = 0.80
// This bound permits a full measurement plus recovery time after contact interruptions.
let maximumBufferedDuration = sessionPurpose == .coherence
? PPGCoherencePolicy.coherenceBufferDurationSeconds
: stabilizationSeconds + measurementSeconds + 60
maximumBufferedSampleCount = max(
Int((targetSampleRate * maximumBufferedDuration).rounded(.up)),
256
)
}
var coreConfiguration: PPGProcessingConfiguration {
PPGProcessingConfiguration(
targetSampleRateHz: targetSampleRate,
minimumFrequencyHz: minimumFrequencyHz,
maximumFrequencyHz: maximumFrequencyHz,
// The UI may finish as soon as 50 clean beats have been collected.
// Keep a 45 s floor for HRV while allowing completion before the 90 s
// safety limit on a clean signal.
minimumAnalysisDurationSeconds: min(max(30, measurementSeconds * 0.75), 45),
maximumInterpolationGapSeconds: maximumInterpolationGapSeconds,
longGapLimitSeconds: longGapLimitSeconds,
minimumContactConfidence: minimumContactConfidence,
maximumSaturationRatio: 0.95,
maximumMotionArtifactScore: 0.95,
minimumPeakProminence: 0.35,
minimumPeakAmplitude: 0.10,
refractoryPeriodSeconds: 0.25,
maximumMethodDisagreementBPM: 8,
minimumQualityScore: 0.55,
minimumIntervalsForPRV: 35,
maximumRejectedIntervalRatioForPRV: 0.45
)
}
}
private struct SessionSnapshot: Sendable {
let configuration: MeasurementConfiguration
let samples: [PPGFrameSample]
let startedAtMonotonicSeconds: Double
let endedAtMonotonicSeconds: Double
let processedFrameCount: Int
let droppedFrameCount: Int
let acquisitionDroppedFrameCount: Int
let usableDurationSeconds: Double
let maximumAcquisitionGapSeconds: Double
let bufferLimitReached: Bool
let terminalReason: String?
}
private let stateLock = NSLock()
private let metricsExtractor = PPGFrameMetricsExtractor()
private let thermalMonitor = PPGThermalMonitor()
private var phase: SessionPhase = .idle
private var configuration: MeasurementConfiguration?
private var contactDetector: PPGFingerContactDetector?
private var terminalThermalState: ThermalState?
private var numericSamples: [PPGFrameSample] = []
private var startedAtMonotonicSeconds = 0.0
private var terminalReason: String?
private var bufferLimitReached = false
private var processedFrameCount = 0
private var droppedFrameCount = 0
private var acquisitionDroppedFrameCount = 0
private var lastObservedTimestamp: Double?
private var recentFrameIntervals: [Double] = []
private var maximumAcquisitionGapSeconds = 0.0
private var validContactStartedAt: Double?
private var acquisitionStartedAt: Double?
private var lastUsableTimestamp: Double?
private var usableDurationSeconds = 0.0
private var lastMetrics: PPGFrameColorMetrics?
private var lastContactEvaluation: PPGContactEvaluation?
private var liveBpm: Double?
private var liveIpiMs: Double?
private var liveBeatSequence = 0
private var lastLiveAnalysisFrame = 0
private var liveBeatTracker = PPGLiveBeatTracker()
private var pendingLiveBeats: [NativeAcceptedBeat] = []
private var liveWindowQuality: Double?
private var totalCollectedSampleCount = 0
override init() {
recentFrameIntervals.reserveCapacity(90)
super.init()
}
func startMeasurement(configuration nativeConfiguration: NativePPGConfiguration) throws {
let parsedConfiguration = try MeasurementConfiguration(native: nativeConfiguration)
let thermalState = thermalMonitor.currentState()
guard !Self.isUnsafe(thermalState) else {
throw HaptikaPPGFrameProcessorError.unsafeThermalState(thermalState.stringValue)
}
stateLock.lock()
defer { stateLock.unlock() }
resetLocked()
configuration = parsedConfiguration
contactDetector = PPGFingerContactDetector(
minimumConfidence: parsedConfiguration.minimumContactConfidence
)
numericSamples.reserveCapacity(
min(
parsedConfiguration.maximumBufferedSampleCount,
Int((parsedConfiguration.targetSampleRate * parsedConfiguration.measurementSeconds).rounded(.up))
)
)
startedAtMonotonicSeconds = ProcessInfo.processInfo.systemUptime
phase = .active
}
func processFrame(frame: any HybridFrameSpec) throws -> PPGFrameUpdate? {
stateLock.lock()
defer { stateLock.unlock() }
let thermalState = thermalMonitor.currentState()
if phase == .thermalStopped {
// Keep notifying the worklet until React Native acknowledges the terminal state and tears
// the capture session down. A single callback could be lost while the JS runtime is busy.
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: terminalThermalState ?? thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard phase == .active, let configuration, let contactDetector else {
// Camera teardown and Nitro calls can overlap by a frame; ignoring it is safe and avoids
// retaining an already-finished frame.
return nil
}
if Self.isUnsafe(thermalState) {
phase = .thermalStopped
terminalThermalState = thermalState
terminalReason = "thermal_\(thermalState.stringValue)"
processedFrameCount += 1
return makeFrameUpdate(
timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp),
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
guard frame.isValid,
let nativeFrame = frame as? any NativeFrame,
let sampleBuffer = nativeFrame.sampleBuffer
else {
throw HaptikaPPGFrameProcessorError.frameUnavailable
}
// The sample buffer is intentionally scoped to this call. The extractor returns value-only
// metrics and never stores the CMSampleBuffer or CVPixelBuffer.
let metrics = try metricsExtractor.extract(from: sampleBuffer)
processedFrameCount += 1
let shouldEmit = processedFrameCount.isMultiple(of: configuration.emitEveryFrames)
guard recordFrameTimingLocked(
timestampSeconds: metrics.timestampSeconds,
configuration: configuration
) else {
if shouldEmit {
return makeFrameUpdate(
timestampSeconds: lastObservedTimestamp ?? metrics.timestampSeconds,
thermalState: thermalState,
metrics: lastMetrics,
evaluation: lastContactEvaluation
)
}
return nil
}
let evaluation = contactDetector.evaluate(metrics)
lastMetrics = metrics
lastContactEvaluation = evaluation
updateAcquisitionLocked(
metrics: metrics,
evaluation: evaluation,
configuration: configuration
)
updateLiveEstimateLocked(configuration: configuration)
guard shouldEmit else { return nil }
return makeFrameUpdate(
timestampSeconds: metrics.timestampSeconds,
thermalState: thermalState,
metrics: metrics,
evaluation: evaluation
)
}
func finishMeasurement() throws -> NativePPGProcessingResult {
let snapshot: SessionSnapshot
stateLock.lock()
switch phase {
case .active, .thermalStopped:
guard let configuration else {
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
phase = .finishing
snapshot = SessionSnapshot(
configuration: configuration,
samples: numericSamples,
startedAtMonotonicSeconds: startedAtMonotonicSeconds,
endedAtMonotonicSeconds: ProcessInfo.processInfo.systemUptime,
processedFrameCount: processedFrameCount,
droppedFrameCount: droppedFrameCount,
acquisitionDroppedFrameCount: acquisitionDroppedFrameCount,
usableDurationSeconds: usableDurationSeconds,
maximumAcquisitionGapSeconds: maximumAcquisitionGapSeconds,
bufferLimitReached: bufferLimitReached,
terminalReason: terminalReason
)
stateLock.unlock()
case .idle, .finishing:
stateLock.unlock()
throw HaptikaPPGFrameProcessorError.measurementNotStarted
}
defer { resetAfterFinishing() }
if let terminalReason = snapshot.terminalReason {
return makeInvalidResult(
snapshot: snapshot,
reason: terminalReason,
issue: "durationInsufficient"
)
}
if snapshot.bufferLimitReached {
return makeInvalidResult(
snapshot: snapshot,
reason: "The numeric measurement buffer reached its safety limit.",
issue: "nonFiniteProcessingResult"
)
}
do {
let processor = try PPGSignalProcessor(
configuration: snapshot.configuration.coreConfiguration
)
let analysis = try processor.analyze(samples: snapshot.samples)
return makeNativeResult(analysis: analysis, snapshot: snapshot)
} catch {
let mapped = Self.mapProcessingError(error)
return makeInvalidResult(
snapshot: snapshot,
reason: mapped.reason,
issue: mapped.issue
)
}
}
func cancelMeasurement() throws {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
func getThermalState() throws -> ThermalState {
thermalMonitor.currentState()
}
private func recordFrameTimingLocked(
timestampSeconds: Double,
configuration: MeasurementConfiguration
) -> Bool {
guard timestampSeconds.isFinite, timestampSeconds >= 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
guard let previousTimestamp = lastObservedTimestamp else {
lastObservedTimestamp = timestampSeconds
return true
}
let interval = timestampSeconds - previousTimestamp
guard interval > 0 else {
droppedFrameCount += 1
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 }
return false
}
if acquisitionStartedAt != nil {
maximumAcquisitionGapSeconds = max(maximumAcquisitionGapSeconds, interval)
}
if recentFrameIntervals.count >= 5 {
let expectedInterval = SignalStatistics.median(recentFrameIntervals)
if expectedInterval > 0, interval > expectedInterval * 1.5 {
let estimatedMissing = min(
max((interval / expectedInterval).rounded() - 1, 0),
100_000
)
let missingCount = Int(estimatedMissing)
droppedFrameCount += missingCount
if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += missingCount }
}
}
if interval <= max(configuration.maximumInterpolationGapSeconds, 0.25) {
recentFrameIntervals.append(interval)
if recentFrameIntervals.count > 90 {
recentFrameIntervals.removeFirst(recentFrameIntervals.count - 90)
}
}
lastObservedTimestamp = timestampSeconds
return true
}
private func updateAcquisitionLocked(
metrics: PPGFrameColorMetrics,
evaluation: PPGContactEvaluation,
configuration: MeasurementConfiguration
) {
// Acquisition is intentionally clock-based rather than contact-gated. A
// contact detector is useful feedback, but making it a hard gate can leave
// a complete minute with zero samples on cameras whose color response is
// different. The signal processor still rejects low-SNR, saturated and
// motion-corrupted portions at the end.
// Store compact numeric samples from the first camera frame. The final
// processor decides which portions are usable. Do not derive a camera
// frame count from the 100 Hz resampling target, and do not throw away a
// good signal merely because the contact heuristic is conservative.
if acquisitionStartedAt == nil {
acquisitionStartedAt = metrics.timestampSeconds
lastUsableTimestamp = nil
}
guard acquisitionStartedAt != nil else { return }
let sample = PPGFrameSample(
timestampSeconds: metrics.timestampSeconds,
red: metrics.redMean,
green: metrics.greenMean,
blue: metrics.blueMean,
luminance: metrics.luminanceMean,
contactConfidence: evaluation.confidence,
saturationRatio: metrics.saturationRatio,
motionArtifactScore: evaluation.motionArtifactScore
)
totalCollectedSampleCount += 1
if configuration.sessionPurpose == .coherence {
numericSamples.append(sample)
if numericSamples.count > configuration.maximumBufferedSampleCount {
numericSamples.removeFirst(numericSamples.count - configuration.maximumBufferedSampleCount)
}
} else if numericSamples.count < configuration.maximumBufferedSampleCount {
numericSamples.append(sample)
} else {
bufferLimitReached = true
}
let opticallyUsable = evaluation.confidence >= 0.35
&& metrics.saturationRatio <= 0.95
&& evaluation.motionArtifactScore <= 0.90
if opticallyUsable {
if let previousTimestamp = lastUsableTimestamp {
let interval = metrics.timestampSeconds - previousTimestamp
if interval > 0, interval <= configuration.maximumInterpolationGapSeconds {
usableDurationSeconds += interval
}
}
lastUsableTimestamp = metrics.timestampSeconds
} else {
// Never bridge quality time across a finger removal or a large movement.
lastUsableTimestamp = nil
}
}
private func makeFrameUpdate(
timestampSeconds: Double,
thermalState: ThermalState,
metrics: PPGFrameColorMetrics?,
evaluation: PPGContactEvaluation?
) -> PPGFrameUpdate {
let signalLevel = min(max((evaluation?.pulsatileAmplitude ?? 0) / 0.03, 0), 1)
let waveformValue: Double
if let metrics {
waveformValue = metrics.greenMean / max(metrics.redMean + metrics.greenMean + metrics.blueMean, 1)
} else {
waveformValue = 0
}
return PPGFrameUpdate(
timestampSeconds: timestampSeconds,
contactState: evaluation?.state ?? .absent,
contactConfidence: min(max(evaluation?.confidence ?? 0, 0), 1),
signalLevel: signalLevel,
saturationRatio: min(max(metrics?.saturationRatio ?? 0, 0), 1),
motionArtifactScore: min(max(evaluation?.motionArtifactScore ?? 0, 0), 1),
collectedSampleCount: Double(totalCollectedSampleCount),
usableDurationSeconds: usableDurationSeconds,
droppedFrameCount: Double(droppedFrameCount),
thermalState: thermalState,
waveformValue: waveformValue,
liveBpm: liveBpm,
liveIpiMs: liveIpiMs,
beatSequence: Double(liveBeatSequence),
acceptedBeats: takePendingLiveBeatsLocked(),
liveWindowQuality: liveWindowQuality
)
}
private func takePendingLiveBeatsLocked() -> [NativeAcceptedBeat] {
let beats = pendingLiveBeats
pendingLiveBeats.removeAll(keepingCapacity: true)
return beats
}
private func updateLiveEstimateLocked(configuration: MeasurementConfiguration) {
// Camera input is constrained to 30 fps. The final 100 Hz configuration
// is a resampling timebase and must not delay live BPM warm-up to 23 s.
let liveInputSampleRate = 30.0
guard numericSamples.count >= Int(liveInputSampleRate * 7),
processedFrameCount - lastLiveAnalysisFrame >= 15
else { return }
lastLiveAnalysisFrame = processedFrameCount
let maximumWindowCount = Int(liveInputSampleRate * 15)
let window = Array(numericSamples.suffix(maximumWindowCount))
let liveConfiguration = PPGProcessingConfiguration(
// A lightweight 30 Hz pass is enough for the live display. The final
// analysis still uses the regular 100 Hz timebase.
targetSampleRateHz: min(configuration.targetSampleRate, 30),
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz,
minimumAnalysisDurationSeconds: 6,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds,
longGapLimitSeconds: configuration.longGapLimitSeconds,
minimumContactConfidence: min(configuration.minimumContactConfidence, 0.45),
maximumSaturationRatio: 0.55,
maximumMotionArtifactScore: 0.65,
minimumPeakProminence: 0.28,
minimumPeakAmplitude: 0.08,
refractoryPeriodSeconds: 0.30,
maximumMethodDisagreementBPM: 12,
minimumQualityScore: 0.35,
minimumIntervalsForPRV: 4,
maximumRejectedIntervalRatioForPRV: 0.45,
filterTapCount: 31
)
guard let analysis = try? PPGSignalProcessor(configuration: liveConfiguration)
.analyze(samples: window)
else { return }
liveWindowQuality = analysis.quality.score
if let bpm = analysis.bpm.bpm ?? analysis.bpm.temporalBpm,
bpm.isFinite, (35...220).contains(bpm) {
liveBpm = bpm
}
let acceptedIntervals = analysis.intervals.filter(\.isAccepted)
if let interval = acceptedIntervals.last,
interval.durationMilliseconds.isFinite {
liveIpiMs = interval.durationMilliseconds
}
// The live analysis uses an overlapping window. Count every newly seen
// accepted interval instead of incrementing only once per analysis pass;
// otherwise a 90 BPM pulse sampled every 0.5 s loses real beats.
let newlyAccepted = liveBeatTracker.ingest(acceptedIntervals)
for beat in newlyAccepted {
liveBeatSequence = beat.sequence
pendingLiveBeats.append(
NativeAcceptedBeat(
sequence: Double(beat.sequence),
timestampSeconds: beat.timestampSeconds,
intervalMs: beat.intervalMs,
confidence: beat.confidence
)
)
}
}
private func makeNativeResult(
analysis: PPGAnalysisResult,
snapshot: SessionSnapshot
) -> NativePPGProcessingResult {
var issues = analysis.quality.issues.map(Self.nativeIssue)
if analysis.bpm.rejectedPeakCount > max(3, analysis.bpm.validPeakCount / 4) {
Self.appendUnique("tooManyRejectedPeaks", to: &issues)
}
let observedDroppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
let droppedFrameRatio = min(
max(max(analysis.quality.droppedFrameRatio, observedDroppedRatio), 0),
1
)
if droppedFrameRatio > 0.12 {
Self.appendUnique("droppedFrames", to: &issues)
}
let timestampJitterRatio = Self.timestampJitterRatio(analysis.timebase)
let longestGapSeconds = max(
analysis.timebase.maximumGapSeconds,
snapshot.maximumAcquisitionGapSeconds
)
let isValid = analysis.status == .valid
let invalidReason = isValid ? nil : Self.invalidReason(for: issues)
let resampledSampleCount = max(
Int((analysis.timebase.durationSeconds * snapshot.configuration.targetSampleRate).rounded(.down)) + 1,
0
)
return NativePPGProcessingResult(
valid: isValid,
invalidReason: invalidReason,
bpm: NativeBPMEstimate(
bpm: analysis.bpm.bpm,
temporalBpm: analysis.bpm.temporalBpm,
frequencyBpm: analysis.bpm.frequencyBpm,
methodAgreementBpm: analysis.bpm.methodAgreementBpm,
validPeakCount: Double(analysis.bpm.validPeakCount),
rejectedPeakCount: Double(analysis.bpm.rejectedPeakCount),
confidence: analysis.bpm.confidence,
selectedChannel: analysis.bpm.selectedChannel.rawValue
),
prv: NativePRVMetrics(
estimatedRmssdMs: analysis.prv.estimatedRmssdMs,
estimatedSdnnMs: analysis.prv.estimatedSdnnMs,
meanIpiMs: analysis.prv.meanIpiMs,
medianIpiMs: analysis.prv.medianIpiMs,
pnn50Percent: analysis.prv.pnn50Percent,
rangeIpiMs: analysis.prv.rangeIpiMs,
modeIpiMs: analysis.prv.modeIpiMs,
amo50Percent: analysis.prv.amo50Percent,
coefficientOfVariation: analysis.prv.coefficientOfVariation,
validIntervalCount: Double(analysis.prv.validIntervalCount),
rejectedIntervalCount: Double(analysis.prv.rejectedIntervalCount),
rejectedIntervalRatio: analysis.prv.rejectedIntervalRatio,
reliable: analysis.prv.reliable
),
quality: NativeSignalQualityResult(
score: analysis.quality.score,
level: analysis.quality.level.rawValue,
usableDurationSeconds: analysis.quality.usableDurationSeconds,
droppedFrameRatio: droppedFrameRatio,
saturationRatio: analysis.quality.saturationRatio,
rejectedIntervalRatio: analysis.quality.rejectedIntervalRatio,
signalToNoiseRatio: analysis.quality.signalToNoiseRatio,
motionArtifactRatio: analysis.quality.motionArtifactRatio,
methodAgreementScore: analysis.quality.methodAgreementScore,
timestampJitterRatio: timestampJitterRatio,
longestGapSeconds: longestGapSeconds,
issues: issues
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: Double(resampledSampleCount)
)
}
private func makeInvalidResult(
snapshot: SessionSnapshot,
reason: String,
issue: String
) -> NativePPGProcessingResult {
let intervals = zip(snapshot.samples, snapshot.samples.dropFirst()).compactMap { pair -> Double? in
let interval = pair.1.timestampSeconds - pair.0.timestampSeconds
return interval.isFinite && interval > 0 ? interval : nil
}
let estimatedRate = intervals.isEmpty ? 0 : 1 / max(SignalStatistics.median(intervals), 1e-9)
let intervalMAD = intervals.isEmpty ? 0 : SignalStatistics.mad(intervals)
let jitterRatio = estimatedRate > 0 ? intervalMAD * estimatedRate : 0
let saturation = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.saturationRatio))
let motion = snapshot.samples.isEmpty
? 0
: SignalStatistics.mean(snapshot.samples.map(\.motionArtifactScore))
let droppedRatio = snapshot.samples.isEmpty
? 0
: Double(snapshot.acquisitionDroppedFrameCount)
/ Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount)
return NativePPGProcessingResult(
valid: false,
invalidReason: reason,
bpm: NativeBPMEstimate(
bpm: nil,
temporalBpm: nil,
frequencyBpm: nil,
methodAgreementBpm: nil,
validPeakCount: 0,
rejectedPeakCount: 0,
confidence: 0,
selectedChannel: "none"
),
prv: NativePRVMetrics(
estimatedRmssdMs: nil,
estimatedSdnnMs: nil,
meanIpiMs: nil,
medianIpiMs: nil,
pnn50Percent: nil,
rangeIpiMs: nil,
modeIpiMs: nil,
amo50Percent: nil,
coefficientOfVariation: nil,
validIntervalCount: 0,
rejectedIntervalCount: 0,
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
reliable: false
),
quality: NativeSignalQualityResult(
score: 0,
level: "invalid",
usableDurationSeconds: snapshot.usableDurationSeconds,
droppedFrameRatio: min(max(droppedRatio, 0), 1),
saturationRatio: min(max(saturation, 0), 1),
rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0,
signalToNoiseRatio: nil,
motionArtifactRatio: min(max(motion, 0), 1),
methodAgreementScore: 0,
timestampJitterRatio: max(jitterRatio, 0),
longestGapSeconds: snapshot.maximumAcquisitionGapSeconds,
issues: [issue]
),
startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds,
endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds,
rawSampleCount: Double(snapshot.samples.count),
resampledSampleCount: 0
)
}
private func resetAfterFinishing() {
stateLock.lock()
resetLocked()
stateLock.unlock()
}
private func resetLocked() {
contactDetector?.reset()
contactDetector = nil
configuration = nil
terminalThermalState = nil
numericSamples.removeAll(keepingCapacity: false)
phase = .idle
startedAtMonotonicSeconds = 0
terminalReason = nil
bufferLimitReached = false
processedFrameCount = 0
droppedFrameCount = 0
acquisitionDroppedFrameCount = 0
lastObservedTimestamp = nil
recentFrameIntervals.removeAll(keepingCapacity: true)
maximumAcquisitionGapSeconds = 0
validContactStartedAt = nil
acquisitionStartedAt = nil
lastUsableTimestamp = nil
usableDurationSeconds = 0
lastMetrics = nil
lastContactEvaluation = nil
liveBpm = nil
liveIpiMs = nil
liveBeatSequence = 0
liveBeatTracker.reset()
lastLiveAnalysisFrame = 0
pendingLiveBeats.removeAll(keepingCapacity: false)
liveWindowQuality = nil
totalCollectedSampleCount = 0
}
private static func isUnsafe(_ state: ThermalState) -> Bool {
PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: state.stringValue)
}
private static func finiteTimestamp(_ timestamp: Double, fallback: Double?) -> Double {
if timestamp.isFinite, timestamp >= 0 { return timestamp }
return fallback ?? 0
}
private static func timestampJitterRatio(_ diagnostics: TimebaseDiagnostics) -> Double {
guard diagnostics.estimatedSampleRateHz > 0 else { return 0 }
return max(diagnostics.frameIntervalMADSeconds * diagnostics.estimatedSampleRateHz, 0)
}
private static func nativeIssue(_ issue: SignalQualityIssue) -> String {
switch issue {
case .insufficientDuration:
return "durationInsufficient"
case .invalidTimestamps, .irregularTimestamps:
return "timestampJitter"
case .longInterruption:
return "longGap"
case .tooManyDroppedFrames:
return "droppedFrames"
case .poorFingerContact:
return "contactInsufficient"
case .excessiveSaturation:
return "saturation"
case .motionArtifacts:
return "motionArtifacts"
case .lowPulsatileAmplitude:
return "lowPulsatileAmplitude"
case .lowSignalToNoise:
return "lowSignalToNoiseRatio"
case .tooFewPeaks:
return "tooFewPeaks"
case .inconsistentBPMMethods:
return "methodDisagreement"
case .tooManyRejectedIntervals:
return "tooManyRejectedIntervals"
case .unstableChannel:
return "channelInstability"
case .nonFiniteInput:
return "nonFiniteProcessingResult"
}
}
private static func appendUnique(_ issue: String, to issues: inout [String]) {
if !issues.contains(issue) { issues.append(issue) }
}
private static func invalidReason(for issues: [String]) -> String {
guard let issue = issues.first else {
return "La qualité du signal est insuffisante."
}
switch issue {
case "contactInsufficient", "contactInterrupted":
return "Le contact du doigt a été insuffisant ou interrompu."
case "durationInsufficient":
return "La durée de signal exploitable est insuffisante."
case "droppedFrames", "timestampJitter", "longGap":
return "La capture comporte trop d’interruptions ou d’images manquantes."
case "saturation":
return "Le signal caméra est trop saturé."
case "motionArtifacts":
return "Trop de mouvements ont perturbé la mesure."
case "lowPulsatileAmplitude", "lowSignalToNoiseRatio":
return "Le signal pulsatile est trop faible ou trop bruité."
case "tooFewPeaks", "tooManyRejectedPeaks", "tooManyRejectedIntervals":
return "Trop peu de pulsations fiables ont été détectées."
case "methodDisagreement":
return "Les estimations temporelle et fréquentielle ne concordent pas."
case "channelInstability":
return "Aucun canal couleur n’est suffisamment stable."
default:
return "Le traitement numérique du signal a produit un résultat invalide."
}
}
private static func mapProcessingError(_ error: Error) -> (reason: String, issue: String) {
guard let signalError = error as? PPGSignalError else {
return ("Le traitement numérique du signal a échoué.", "nonFiniteProcessingResult")
}
switch signalError {
case .insufficientSamples, .insufficientDuration:
return ("La durée de signal exploitable est insuffisante.", "durationInsufficient")
case .invalidTimestamps:
return ("Les horodatages caméra sont invalides.", "timestampJitter")
case .noUsableChannel:
return ("Aucun canal couleur n’est exploitable.", "channelInstability")
case .invalidConfiguration:
return ("La configuration du traitement du signal est invalide.", "nonFiniteProcessingResult")
}
}
}
private enum HaptikaPPGFrameProcessorError: LocalizedError {
case invalidConfiguration(String)
case unsafeThermalState(String)
case frameUnavailable
case measurementNotStarted
var errorDescription: String? {
switch self {
case .invalidConfiguration(let reason):
return "Invalid PPG configuration: \(reason)."
case .unsafeThermalState(let state):
return "PPG measurement refused because the iPhone thermal state is \(state)."
case .frameUnavailable:
return "The VisionCamera frame is invalid or does not expose a CMSampleBuffer."
case .measurementNotStarted:
return "No PPG measurement is currently active."
}
}
}
@@ -0,0 +1,132 @@
import Foundation
struct PPGContactEvaluation: Sendable {
let state: FingerContactState
let confidence: Double
let pulsatileAmplitude: Double
let motionArtifactScore: Double
}
/// Camera-agnostic coverage detector.
///
/// Contact is deliberately not a prerequisite for collecting frames. This
/// class only coaches the user and supplies soft quality metadata. The final
/// decision is made from the periodic optical signal, never from skin colour.
final class PPGFingerContactDetector {
private let requiredStableFrames = 6
private let requiredReleaseFrames = 12
private let historyLimit = 90
private var history: [PPGFrameColorMetrics] = []
private var state: FingerContactState = .adjusting
private var stableFrames = 0
private var releaseFrames = 0
init(minimumConfidence _: Double) {
history.reserveCapacity(historyLimit)
}
func reset() {
history.removeAll(keepingCapacity: true)
state = .adjusting
stableFrames = 0
releaseFrames = 0
}
func evaluate(_ metrics: PPGFrameColorMetrics) -> PPGContactEvaluation {
let previous = history.last
history.append(metrics)
if history.count > historyLimit {
history.removeFirst(history.count - historyLimit)
}
let brightness = metrics.luminanceMean / 255
let red = metrics.redMean / 255
let green = metrics.greenMean / 255
let blue = metrics.blueMean / 255
let chroma = max(red, green, blue) - min(red, green, blue)
let spatialDeviation = sqrt(max(metrics.redVariance + metrics.greenVariance, 0) / 2)
let uniformity = clamp01(1 - spatialDeviation / 82)
let exposure = smoothWindow(brightness, lower: 0.025, upper: 0.985, feather: 0.10)
let warmth = clamp01(0.5 + (red - blue) * 1.4)
let coverage = clamp01(0.45 * uniformity + 0.30 * exposure + 0.15 * warmth + 0.10 * min(chroma * 3, 1))
let motion = motionScore(current: metrics, previous: previous)
let amplitude = pulsatileAmplitude()
let temporalStability = clamp01(1 - motion / 0.55)
let confidence = clamp01(0.72 * coverage + 0.28 * temporalStability)
let candidate: FingerContactState
if brightness < 0.015 || (uniformity < 0.12 && warmth < 0.25) {
candidate = .absent
} else if brightness > 0.992 || metrics.saturationRatio > 0.92 {
candidate = .overexposed
} else if history.count >= 15 && motion > 0.68 {
candidate = .unstable
} else if confidence >= 0.48 {
candidate = .valid
} else {
candidate = .adjusting
}
applyHysteresis(candidate)
return PPGContactEvaluation(
state: state,
confidence: confidence,
pulsatileAmplitude: amplitude,
motionArtifactScore: motion
)
}
private func applyHysteresis(_ candidate: FingerContactState) {
if candidate == .valid {
stableFrames += 1
releaseFrames = 0
if stableFrames >= requiredStableFrames { state = .valid }
return
}
stableFrames = 0
releaseFrames += 1
// Brief changes are feedback, not session resets. Holding the last valid
// state avoids a flashing UI while automatic exposure settles.
if state == .valid && releaseFrames < requiredReleaseFrames { return }
state = candidate
}
private func pulsatileAmplitude() -> Double {
let window = history.suffix(60)
guard window.count >= 20 else { return 0 }
let normalized = window.map {
$0.greenMean / max($0.redMean + $0.greenMean + $0.blueMean, 1)
}
return max((normalized.max() ?? 0) - (normalized.min() ?? 0), 0)
}
private func motionScore(
current: PPGFrameColorMetrics,
previous: PPGFrameColorMetrics?
) -> Double {
guard let previous else { return 0 }
let lightDelta = abs(current.luminanceMean - previous.luminanceMean) / 255
let redDelta = abs(current.redMean - previous.redMean) / 255
let greenDelta = abs(current.greenMean - previous.greenMean) / 255
// Normal pulse modulation is small. Only large frame-to-frame steps are
// considered motion, regardless of the absolute colour of the fingertip.
return clamp01(max(lightDelta / 0.16, (redDelta + greenDelta) / 0.28))
}
private func smoothWindow(
_ value: Double,
lower: Double,
upper: Double,
feather: Double
) -> Double {
if value <= lower || value >= upper { return 0 }
if value < lower + feather { return clamp01((value - lower) / feather) }
if value > upper - feather { return clamp01((upper - value) / feather) }
return 1
}
private func clamp01(_ value: Double) -> Double {
min(max(value.isFinite ? value : 0, 0), 1)
}
}
@@ -0,0 +1,237 @@
import CoreMedia
import CoreVideo
import Foundation
struct PPGFrameColorMetrics: Sendable {
let timestampSeconds: Double
let redMean: Double
let greenMean: Double
let blueMean: Double
let redVariance: Double
let greenVariance: Double
let luminanceMean: Double
let saturationRatio: Double
}
enum PPGFrameMetricsExtractorError: Error {
case invalidTimestamp
case unsupportedPixelFormat(OSType)
case pixelBufferLockFailed(CVReturn)
case missingPlane
case missingBaseAddress
case emptyRegionOfInterest
}
/// Extracts compact color statistics from a central ROI without retaining or copying a frame.
final class PPGFrameMetricsExtractor {
private let gridSize: Int
private let roiFraction: Double
init(gridSize: Int = 32, roiFraction: Double = 0.55) {
self.gridSize = max(8, gridSize)
self.roiFraction = min(max(roiFraction, 0.2), 0.9)
}
func extract(from sampleBuffer: CMSampleBuffer) throws -> PPGFrameColorMetrics {
let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
guard timestamp.isFinite, timestamp >= 0 else {
throw PPGFrameMetricsExtractorError.invalidTimestamp
}
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
throw PPGFrameMetricsExtractorError.missingBaseAddress
}
let lockFlags = CVPixelBufferLockFlags.readOnly
let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, lockFlags)
guard lockStatus == kCVReturnSuccess else {
throw PPGFrameMetricsExtractorError.pixelBufferLockFailed(lockStatus)
}
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, lockFlags) }
let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer)
switch pixelFormat {
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
return try extractBiPlanarYUV(
from: pixelBuffer,
timestamp: timestamp,
isFullRange: true
)
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
return try extractBiPlanarYUV(
from: pixelBuffer,
timestamp: timestamp,
isFullRange: false
)
case kCVPixelFormatType_32BGRA:
return try extractBGRA(from: pixelBuffer, timestamp: timestamp)
default:
throw PPGFrameMetricsExtractorError.unsupportedPixelFormat(pixelFormat)
}
}
private func extractBiPlanarYUV(
from pixelBuffer: CVPixelBuffer,
timestamp: Double,
isFullRange: Bool
) throws -> PPGFrameColorMetrics {
guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else {
throw PPGFrameMetricsExtractorError.missingPlane
}
guard let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0),
let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1)
else {
throw PPGFrameMetricsExtractorError.missingBaseAddress
}
let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0)
let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0)
let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1)
let yBytes = yBase.assumingMemoryBound(to: UInt8.self)
let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self)
let roi = regionOfInterest(width: width, height: height)
let stepX = max(roi.width / gridSize, 1)
let stepY = max(roi.height / gridSize, 1)
var accumulator = ColorAccumulator()
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
let yValue = Double(yBytes[y * yStride + x])
let uvOffset = (y / 2) * uvStride + (x / 2) * 2
let cb = Double(uvBytes[uvOffset])
let cr = Double(uvBytes[uvOffset + 1])
let rgb = convertYCbCrToRGB(y: yValue, cb: cb, cr: cr, isFullRange: isFullRange)
accumulator.add(red: rgb.red, green: rgb.green, blue: rgb.blue, luminance: yValue)
}
}
return try accumulator.result(timestamp: timestamp)
}
private func extractBGRA(
from pixelBuffer: CVPixelBuffer,
timestamp: Double
) throws -> PPGFrameColorMetrics {
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
throw PPGFrameMetricsExtractorError.missingBaseAddress
}
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let bytes = baseAddress.assumingMemoryBound(to: UInt8.self)
let roi = regionOfInterest(width: width, height: height)
let stepX = max(roi.width / gridSize, 1)
let stepY = max(roi.height / gridSize, 1)
var accumulator = ColorAccumulator()
for y in stride(from: roi.minY, to: roi.maxY, by: stepY) {
for x in stride(from: roi.minX, to: roi.maxX, by: stepX) {
let offset = y * bytesPerRow + x * 4
let blue = Double(bytes[offset])
let green = Double(bytes[offset + 1])
let red = Double(bytes[offset + 2])
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
accumulator.add(red: red, green: green, blue: blue, luminance: luminance)
}
}
return try accumulator.result(timestamp: timestamp)
}
private func regionOfInterest(width: Int, height: Int) -> (
minX: Int,
maxX: Int,
minY: Int,
maxY: Int,
width: Int,
height: Int
) {
let roiWidth = max(Int(Double(width) * roiFraction), 1)
let roiHeight = max(Int(Double(height) * roiFraction), 1)
let minX = max((width - roiWidth) / 2, 0)
let minY = max((height - roiHeight) / 2, 0)
return (minX, minX + roiWidth, minY, minY + roiHeight, roiWidth, roiHeight)
}
private func convertYCbCrToRGB(
y: Double,
cb: Double,
cr: Double,
isFullRange: Bool
) -> (red: Double, green: Double, blue: Double) {
let red: Double
let green: Double
let blue: Double
if isFullRange {
let shiftedCb = cb - 128
let shiftedCr = cr - 128
red = y + 1.402 * shiftedCr
green = y - 0.344_136 * shiftedCb - 0.714_136 * shiftedCr
blue = y + 1.772 * shiftedCb
} else {
let shiftedY = max(y - 16, 0)
let shiftedCb = cb - 128
let shiftedCr = cr - 128
red = 1.164_383 * shiftedY + 1.596_027 * shiftedCr
green = 1.164_383 * shiftedY - 0.391_762 * shiftedCb - 0.812_968 * shiftedCr
blue = 1.164_383 * shiftedY + 2.017_232 * shiftedCb
}
return (clampByte(red), clampByte(green), clampByte(blue))
}
private func clampByte(_ value: Double) -> Double {
min(max(value, 0), 255)
}
}
private struct ColorAccumulator {
private(set) var count = 0
private var redMean = 0.0
private var greenMean = 0.0
private var blueMean = 0.0
private var luminanceMean = 0.0
private var redM2 = 0.0
private var greenM2 = 0.0
private var saturatedCount = 0
mutating func add(red: Double, green: Double, blue: Double, luminance: Double) {
count += 1
let n = Double(count)
let redDelta = red - redMean
redMean += redDelta / n
redM2 += redDelta * (red - redMean)
let greenDelta = green - greenMean
greenMean += greenDelta / n
greenM2 += greenDelta * (green - greenMean)
blueMean += (blue - blueMean) / n
luminanceMean += (luminance - luminanceMean) / n
// A fingertip lit by the torch commonly clips only the red channel while
// green still contains a clean pulsatile waveform. Count true optical
// clipping only when both useful color channels (or luminance) are clipped.
if (red >= 250 && green >= 250) || luminance >= 250 {
saturatedCount += 1
}
}
func result(timestamp: Double) throws -> PPGFrameColorMetrics {
guard count > 0 else {
throw PPGFrameMetricsExtractorError.emptyRegionOfInterest
}
let divisor = Double(max(count - 1, 1))
return PPGFrameColorMetrics(
timestampSeconds: timestamp,
redMean: redMean,
greenMean: greenMean,
blueMean: blueMean,
redVariance: redM2 / divisor,
greenVariance: greenM2 / divisor,
luminanceMean: luminanceMean,
saturationRatio: Double(saturatedCount) / Double(count)
)
}
}
@@ -0,0 +1,303 @@
import CoreHaptics
import Foundation
import NitroModules
/// HAPTIKA.CARDIAC_MIRROR_BEAT v1.0.0
///
/// This pattern is deliberately isolated from generic notification haptics. A
/// short transient with a controlled sharpness gives the user a repeatable
/// “heartbeat” texture without pretending to reproduce a clinical waveform.
final class HybridHaptikaCardiacMirror: HybridHaptikaCardiacMirrorSpec {
private static let patternID = "HAPTIKA.CARDIAC_MIRROR_BEAT"
private static let patternVersion = "1.0.0"
// Kept at zero for Nitro ABI compatibility. Public Watch workout APIs expose
// cadence updates, not exact beat events, so Haptika advertises no latency
// SLA between a biological beat and the reconstructed iPhone pulse.
private static let latencyBudgetMs = 0.0
private let lock = NSLock()
private var engine: CHHapticEngine?
private var patternPlayer: CHHapticPatternPlayer?
private var regulationPlayer: CHHapticAdvancedPatternPlayer?
private var supported: Bool
override init() {
self.supported = CHHapticEngine.capabilitiesForHardware().supportsHaptics
super.init()
guard supported else { return }
configureEngine()
}
func isSupported() throws -> Bool {
return supported
}
func patternInfo() throws -> NativeCardiacMirrorPatternInfo {
return NativeCardiacMirrorPatternInfo(
id: Self.patternID,
version: Self.patternVersion,
latencyBudgetMs: Self.latencyBudgetMs
)
}
func start(targetBpm: Double) throws {
guard supported else { return }
lock.lock()
defer { lock.unlock() }
do {
try engine?.start()
if patternPlayer == nil {
patternPlayer = try makePatternPlayer()
}
} catch {
// Haptics are optional and must never block the visual experience.
patternPlayer = nil
}
}
func pulse() throws {
guard supported else { return }
lock.lock()
defer { lock.unlock() }
do {
try engine?.start()
if patternPlayer == nil {
patternPlayer = try makePatternPlayer()
}
try patternPlayer?.start(atTime: CHHapticTimeImmediate)
} catch {
patternPlayer = nil
}
}
func stop() throws {
lock.lock()
defer { lock.unlock() }
patternPlayer = nil
regulationPlayer = nil
engine?.stop(completionHandler: nil)
}
func playRegulationProtocol(kind: String) throws -> Double {
guard supported else { return 0 }
lock.lock()
defer { lock.unlock() }
do {
try engine?.start()
try regulationPlayer?.stop(atTime: CHHapticTimeImmediate)
let configuration = try makeRegulationPattern(kind: kind)
guard let engine else { return 0 }
let player = try engine.makeAdvancedPlayer(with: configuration.pattern)
player.loopEnabled = configuration.loops
regulationPlayer = player
try player.start(atTime: CHHapticTimeImmediate)
return configuration.durationSeconds
} catch {
regulationPlayer = nil
return 0
}
}
func stopRegulationProtocol() throws {
lock.lock()
defer { lock.unlock() }
try? regulationPlayer?.stop(atTime: CHHapticTimeImmediate)
regulationPlayer = nil
}
func startWatchSession() throws {
HaptikaWatchConnectivityService.shared.activate()
}
func watchReachable() throws -> Bool {
return HaptikaWatchConnectivityService.shared.state().reachable
}
func latestHeartbeat() throws -> NativeCardiacMirrorHeartbeat? {
guard let sample = HaptikaWatchConnectivityService.shared.latestHeartRate() else {
return nil
}
return NativeCardiacMirrorHeartbeat(
timestampSeconds: sample.timestampSeconds,
bpm: sample.bpm
)
}
private func configureEngine() {
do {
let hapticEngine = try CHHapticEngine()
hapticEngine.resetHandler = { [weak self] in
self?.lock.lock()
defer { self?.lock.unlock() }
self?.patternPlayer = nil
}
hapticEngine.stoppedHandler = { [weak self] _ in
self?.lock.lock()
defer { self?.lock.unlock() }
self?.patternPlayer = nil
}
engine = hapticEngine
} catch {
supported = false
}
}
private func makePatternPlayer() throws -> CHHapticPatternPlayer {
guard let engine else {
throw NSError(domain: "HaptikaCardiacMirror", code: 1)
}
let intensity = CHHapticEventParameter(
parameterID: .hapticIntensity,
value: 0.78
)
let sharpness = CHHapticEventParameter(
parameterID: .hapticSharpness,
value: 0.32
)
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [intensity, sharpness],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [event], parameters: [])
return try engine.makePlayer(with: pattern)
}
private func makeRegulationPattern(
kind: String
) throws -> (pattern: CHHapticPattern, durationSeconds: Double, loops: Bool) {
switch kind {
case "coherence55":
return (try makeBreathingPattern(inhale: 5, exhale: 5), 10, true)
case "relax46":
return (try makeBreathingPattern(inhale: 4, exhale: 6), 10, true)
case "box4444":
return (try makePhasedBreathingPattern(inhale: 4, hold: 4, exhale: 4, emptyHold: 4), 16, true)
case "breathing478":
return (try makePhasedBreathingPattern(inhale: 4, hold: 7, exhale: 8, emptyHold: 0), 19, true)
case "sosStress":
return (try makeSettlingPattern(intensity: 0.25, duration: 60), 62.2, false)
case "microAnchor":
return (try makeSettlingPattern(intensity: 0.15, duration: 35), 37, false)
default:
throw NSError(domain: "HaptikaRegulation", code: 2)
}
}
private func makeBreathingPattern(
inhale: TimeInterval,
exhale: TimeInterval
) throws -> CHHapticPattern {
let inhaleEvent = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.55),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08)
],
relativeTime: 0,
duration: inhale
)
let transition = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.65)
],
relativeTime: inhale
)
let exhaleEvent = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.24),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04)
],
relativeTime: inhale + 0.08,
duration: exhale - 0.08
)
return try CHHapticPattern(
events: [inhaleEvent, transition, exhaleEvent],
parameters: []
)
}
private func makePhasedBreathingPattern(
inhale: TimeInterval,
hold: TimeInterval,
exhale: TimeInterval,
emptyHold: TimeInterval
) throws -> CHHapticPattern {
let inhaleEvent = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.48),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08)
],
relativeTime: 0,
duration: inhale
)
let fullHoldMarker = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.42),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.52)
],
relativeTime: inhale
)
let exhaleEvent = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.20),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.03)
],
relativeTime: inhale + hold,
duration: exhale
)
var events = [inhaleEvent, fullHoldMarker, exhaleEvent]
if emptyHold > 0 {
events.append(
CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.25),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.25)
],
relativeTime: inhale + hold + exhale + emptyHold - 0.02
)
)
}
return try CHHapticPattern(events: events, parameters: [])
}
private func makeSettlingPattern(
intensity: Float,
duration: TimeInterval
) throws -> CHHapticPattern {
var events: [CHHapticEvent] = []
for index in 0..<3 {
events.append(
CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.62),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.42)
],
relativeTime: TimeInterval(index) * 0.6
)
)
}
events.append(
CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04)
],
relativeTime: 2,
duration: duration
)
)
return try CHHapticPattern(events: events, parameters: [])
}
}
@@ -0,0 +1,451 @@
import Foundation
import HealthKit
import NitroModules
/// Read-only HealthKit access used as dated physiological context.
///
/// This module deliberately uses sample queries rather than observer/live queries. It never
/// creates heartbeat series and never treats historical HealthKit values as a current reading.
final class HybridHaptikaHealthKit: HybridHaptikaHealthKitSpec {
private static let defaultLookbackDays = 30.0
private static let minimumLookbackDays = 1.0
private static let maximumLookbackDays = 365.0
private static let maximumRecentHeartRateSamples = 100
private let healthStore: HKHealthStore
override init() {
self.healthStore = HKHealthStore()
super.init()
}
func isAvailable() throws -> Bool {
return HKHealthStore.isHealthDataAvailable()
}
func requestReadAuthorization() throws -> Promise<Bool> {
guard HKHealthStore.isHealthDataAvailable() else {
return Promise.resolved(withResult: false)
}
let healthStore = self.healthStore
let readTypes = Self.readTypes()
return Promise<Bool>.async {
do {
try await healthStore.requestAuthorization(
toShare: Set<HKSampleType>(),
read: readTypes
)
// HealthKit intentionally does not reveal whether read access was granted. A successful
// authorization request only means that the sheet completed without an operational error.
return true
} catch {
// HealthKit is optional; cancellation, a missing entitlement, or a locked/unavailable
// store must not block the camera measurement flow.
return false
}
}
}
func fetchContext(lookbackDays: Double) throws -> Promise<NativeHealthKitContext> {
let fetchedAt = Date()
guard HKHealthStore.isHealthDataAvailable() else {
return Promise.resolved(
withResult: Self.emptyContext(
available: false,
authorizationRequestSettled: false,
fetchedAt: fetchedAt
)
)
}
let healthStore = self.healthStore
let boundedLookbackDays = Self.boundedLookbackDays(lookbackDays)
return Promise<NativeHealthKitContext>.async {
let authorizationRequestSettled = await Self.authorizationRequestIsSettled(
healthStore: healthStore
)
// Do not query before the optional permission flow has been presented. When access has been
// denied, HealthKit protects the decision by returning no samples, just as it does when the
// store genuinely has no samples.
guard authorizationRequestSettled else {
return Self.emptyContext(
available: true,
authorizationRequestSettled: false,
fetchedAt: fetchedAt
)
}
let startDate = fetchedAt.addingTimeInterval(-boundedLookbackDays * 86_400.0)
async let restingSamples = Self.quantitySamplesSafely(
healthStore: healthStore,
type: Self.quantityType(.restingHeartRate),
startDate: startDate,
endDate: fetchedAt,
limit: 1
)
async let hrvSamples = Self.quantitySamplesSafely(
healthStore: healthStore,
type: Self.quantityType(.heartRateVariabilitySDNN),
startDate: startDate,
endDate: fetchedAt,
limit: 1
)
async let heartRateSamples = Self.quantitySamplesSafely(
healthStore: healthStore,
type: Self.quantityType(.heartRate),
startDate: startDate,
endDate: fetchedAt,
limit: Self.maximumRecentHeartRateSamples
)
let (resting, hrv, heartRates) = await (
restingSamples,
hrvSamples,
heartRateSamples
)
let latestResting = resting.first
let latestHrv = hrv.first
let nativeResting = latestResting.flatMap {
Self.makeNativeQuantitySample(
$0,
metricType: .restingheartrate,
unit: Self.beatsPerMinuteUnit,
unitLabel: "count/min"
)
}
let nativeHrv = latestHrv.flatMap {
Self.makeNativeQuantitySample(
$0,
metricType: .hrvsdnn,
unit: Self.millisecondUnit,
unitLabel: "ms"
)
}
let nativeHeartRates = heartRates.compactMap {
Self.makeNativeQuantitySample(
$0,
metricType: .heartrate,
unit: Self.beatsPerMinuteUnit,
unitLabel: "count/min"
)
}
return NativeHealthKitContext(
available: true,
// This means the authorization request no longer needs presenting. Apple deliberately
// makes an explicit read denial indistinguishable from an authorized store with no data.
authorized: authorizationRequestSettled,
latestRestingHeartRate: nativeResting,
latestHrvSdnn: nativeHrv,
recentHeartRateSamples: nativeHeartRates,
contextConfidence: Self.contextConfidence(
latestResting: latestResting,
latestHrv: latestHrv,
heartRates: heartRates,
fetchedAt: fetchedAt,
lookbackDays: boundedLookbackDays
),
fetchedAt: Self.iso8601String(from: fetchedAt)
)
}
}
func fetchLatestHeartbeatSeries() throws -> Promise<NativeHeartbeatSeriesSummary?> {
guard HKHealthStore.isHealthDataAvailable() else {
return Promise.resolved(withResult: nil)
}
let healthStore = self.healthStore
return Promise<NativeHeartbeatSeriesSummary?>.async {
guard await Self.authorizationRequestIsSettled(healthStore: healthStore) else {
return nil
}
do {
guard let sample = try await Self.latestHeartbeatSeriesSample(
healthStore: healthStore
) else {
return nil
}
var heartbeatTimes: [Double] = []
var precededByGapIndexes: [Double] = []
let descriptor = HKHeartbeatSeriesQueryDescriptor(sample)
for try await heartbeat in descriptor.results(for: healthStore) {
let time = heartbeat.timeIntervalSinceStart
guard time.isFinite, time >= 0 else {
continue
}
heartbeatTimes.append(time)
if heartbeat.precededByGap {
precededByGapIndexes.append(Double(heartbeatTimes.count - 1))
}
}
guard !heartbeatTimes.isEmpty else {
return nil
}
let source = sample.sourceRevision.source
return NativeHeartbeatSeriesSummary(
startDate: Self.iso8601String(from: sample.startDate),
endDate: Self.iso8601String(from: sample.endDate),
sourceName: Self.nonEmpty(source.name),
sourceBundleIdentifier: Self.nonEmpty(source.bundleIdentifier),
heartbeatTimesSinceSeriesStartSeconds: heartbeatTimes,
precededByGapIndexes: precededByGapIndexes
)
} catch {
// A read denial, no accessible series, protected-data lock, or query failure all degrade to
// an absent optional context. None of them should affect the camera PPG measurement.
return nil
}
}
}
private static var beatsPerMinuteUnit: HKUnit {
return HKUnit.count().unitDivided(by: .minute())
}
private static var millisecondUnit: HKUnit {
return HKUnit.secondUnit(with: .milli)
}
private static func quantityType(
_ identifier: HKQuantityTypeIdentifier
) -> HKQuantityType? {
return HKObjectType.quantityType(forIdentifier: identifier)
}
private static func readTypes() -> Set<HKObjectType> {
var types = Set<HKObjectType>()
if let heartRate = quantityType(.heartRate) {
types.insert(heartRate)
}
if let restingHeartRate = quantityType(.restingHeartRate) {
types.insert(restingHeartRate)
}
if let hrvSdnn = quantityType(.heartRateVariabilitySDNN) {
types.insert(hrvSdnn)
}
// Read-only access to series already present in HealthKit. No builder or write type is used.
types.insert(HKSeriesType.heartbeat())
return types
}
private static func authorizationRequestIsSettled(
healthStore: HKHealthStore
) async -> Bool {
do {
let status = try await healthStore.statusForAuthorizationRequest(
toShare: Set<HKSampleType>(),
read: readTypes()
)
return status == .unnecessary
} catch {
return false
}
}
private static func quantitySamplesSafely(
healthStore: HKHealthStore,
type: HKQuantityType?,
startDate: Date,
endDate: Date,
limit: Int
) async -> [HKQuantitySample] {
guard let type else {
return []
}
do {
return try await quantitySamples(
healthStore: healthStore,
type: type,
startDate: startDate,
endDate: endDate,
limit: limit
)
} catch {
return []
}
}
private static func quantitySamples(
healthStore: HKHealthStore,
type: HKQuantityType,
startDate: Date,
endDate: Date,
limit: Int
) async throws -> [HKQuantitySample] {
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: endDate,
options: [.strictEndDate]
)
let sort = NSSortDescriptor(
key: HKSampleSortIdentifierEndDate,
ascending: false
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: type,
predicate: predicate,
limit: limit,
sortDescriptors: [sort]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(
returning: samples?.compactMap { $0 as? HKQuantitySample } ?? []
)
}
healthStore.execute(query)
}
}
private static func latestHeartbeatSeriesSample(
healthStore: HKHealthStore
) async throws -> HKHeartbeatSeriesSample? {
let sort = NSSortDescriptor(
key: HKSampleSortIdentifierEndDate,
ascending: false
)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: HKSeriesType.heartbeat(),
predicate: nil,
limit: 1,
sortDescriptors: [sort]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(
returning: samples?.first as? HKHeartbeatSeriesSample
)
}
healthStore.execute(query)
}
}
private static func makeNativeQuantitySample(
_ sample: HKQuantitySample,
metricType: HealthMetricType,
unit: HKUnit,
unitLabel: String
) -> NativeHealthMetricSample? {
let value = sample.quantity.doubleValue(for: unit)
guard value.isFinite else {
return nil
}
let source = sample.sourceRevision.source
let deviceName = nonEmpty(sample.device?.name) ?? nonEmpty(sample.device?.model)
return NativeHealthMetricSample(
type: metricType,
value: value,
unit: unitLabel,
startDate: iso8601String(from: sample.startDate),
endDate: iso8601String(from: sample.endDate),
sourceName: nonEmpty(source.name),
sourceBundleIdentifier: nonEmpty(source.bundleIdentifier),
deviceName: deviceName
)
}
/// A conservative availability-and-recency score for optional context only. It is not a signal
/// quality score and must not be interpreted as confidence in a stress or medical conclusion.
private static func contextConfidence(
latestResting: HKQuantitySample?,
latestHrv: HKQuantitySample?,
heartRates: [HKQuantitySample],
fetchedAt: Date,
lookbackDays: Double
) -> Double {
let horizon = max(lookbackDays * 86_400.0, 1.0)
func freshness(of sample: HKSample) -> Double {
let age = max(fetchedAt.timeIntervalSince(sample.endDate), 0.0)
return min(max(1.0 - age / horizon, 0.0), 1.0)
}
var score = 0.0
if let latestResting {
score += 0.35 * freshness(of: latestResting)
}
if let latestHrv {
score += 0.35 * freshness(of: latestHrv)
}
if let latestHeartRate = heartRates.first {
let sampleCoverage = min(Double(heartRates.count) / 5.0, 1.0)
score += 0.20 * freshness(of: latestHeartRate)
score += 0.10 * sampleCoverage
}
return min(max(score, 0.0), 1.0)
}
private static func emptyContext(
available: Bool,
authorizationRequestSettled: Bool,
fetchedAt: Date
) -> NativeHealthKitContext {
return NativeHealthKitContext(
available: available,
authorized: authorizationRequestSettled,
latestRestingHeartRate: nil,
latestHrvSdnn: nil,
recentHeartRateSamples: [],
contextConfidence: 0,
fetchedAt: iso8601String(from: fetchedAt)
)
}
private static func boundedLookbackDays(_ value: Double) -> Double {
guard value.isFinite else {
return defaultLookbackDays
}
return min(max(value, minimumLookbackDays), maximumLookbackDays)
}
private static func iso8601String(from date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
private static func nonEmpty(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,18 @@
import Foundation
struct PPGThermalMonitor {
func currentState() -> ThermalState {
switch ProcessInfo.processInfo.thermalState {
case .nominal:
return .nominal
case .fair:
return .fair
case .serious:
return .serious
case .critical:
return .critical
@unknown default:
return .unknown
}
}
}
@@ -0,0 +1,17 @@
import Foundation
/// Pure, testable safety gate used before and during camera acquisition.
/// Serious and critical states stop the measurement; unknown is conservative
/// only in the sense that no torch escalation is attempted elsewhere.
public enum PPGThermalSafetyPolicy {
public static func shouldStopMeasurement(forStateName stateName: String) -> Bool {
switch stateName.lowercased() {
case "serious", "critical":
return true
case "nominal", "fair", "unknown":
return false
default:
return false
}
}
}
@@ -0,0 +1,196 @@
import Foundation
public struct FrequencyBPMEstimate: Codable, Equatable, Sendable {
public var bpm: Double?
public var autocorrelation: Double
public var confidence: Double
public init(bpm: Double?, autocorrelation: Double, confidence: Double) {
self.bpm = bpm
self.autocorrelation = autocorrelation.isFinite ? autocorrelation : 0
self.confidence = SignalStatistics.clamp01(confidence)
}
}
public enum PPGBPMEstimator {
public static func estimate(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
peakDetection: PeakDetectionResult,
selectedChannel: PPGSignalChannel,
configuration: PPGProcessingConfiguration
) throws -> BPMEstimate {
let configuration = try configuration.validated()
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 5
else {
throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count)
}
let acceptedIntervals = peakDetection.acceptedIntervals.map(\.durationMilliseconds)
let temporalBPM: Double? = acceptedIntervals.count >= 4
? 60_000 / SignalStatistics.median(acceptedIntervals)
: nil
let frequency = frequencyEstimate(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz
)
// Autocorrelation can lock onto the second harmonic (2×) or a sub-harmonic
// (0.5×). Prefer the equivalent candidate closest to the clean beat-to-beat
// estimate before judging the two methods as inconsistent.
let adjustedFrequencyBPM = temporalBPM.flatMap { temporal -> Double? in
guard let raw = frequency.bpm else { return nil }
return [raw, raw / 2, raw * 2]
.filter {
$0 >= configuration.minimumFrequencyHz * 60
&& $0 <= configuration.maximumFrequencyHz * 60
}
.min(by: { abs($0 - temporal) < abs($1 - temporal) })
} ?? frequency.bpm
let agreement = temporalBPM.flatMap { temporal in
adjustedFrequencyBPM.map { abs(temporal - $0) }
}
let agreementScore = agreement.map {
SignalStatistics.clamp01(1 - $0 / max(configuration.maximumMethodDisagreementBPM, 1e-9))
} ?? 0
let finalBPM: Double?
if let temporalBPM,
let frequencyBPM = adjustedFrequencyBPM,
let agreement,
agreement <= configuration.maximumMethodDisagreementBPM {
finalBPM = 0.65 * temporalBPM + 0.35 * frequencyBPM
} else if let temporalBPM,
acceptedIntervals.count >= 8,
peakDetection.confidence >= 0.55 {
// A long, regular sequence of detected beats is sufficient for mean HR.
// Keep PRV subject to the stricter downstream interval-quality checks.
finalBPM = temporalBPM
} else if let frequencyBPM = adjustedFrequencyBPM,
frequency.confidence >= 0.55 {
finalBPM = frequencyBPM
} else {
finalBPM = nil
}
let countScore = min(Double(acceptedIntervals.count) / 10, 1)
let confidence = 0.40 * peakDetection.confidence
+ 0.25 * frequency.confidence
+ 0.25 * agreementScore
+ 0.10 * countScore
return BPMEstimate(
bpm: finalBPM,
temporalBpm: temporalBPM,
frequencyBpm: adjustedFrequencyBPM,
methodAgreementBpm: agreement,
validPeakCount: peakDetection.peaks.count,
rejectedPeakCount: peakDetection.rejectedIntervalCount,
confidence: confidence,
selectedChannel: selectedChannel,
selectedPolarity: peakDetection.selectedPolarity
)
}
public static func frequencyEstimate(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
minimumFrequencyHz: Double,
maximumFrequencyHz: Double
) -> FrequencyBPMEstimate {
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 12,
minimumFrequencyHz > 0,
maximumFrequencyHz > minimumFrequencyHz
else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let intervals = zip(timestampsSeconds, timestampsSeconds.dropFirst())
.map { $1 - $0 }
.filter { $0.isFinite && $0 > 0 }
let sampleInterval = SignalStatistics.median(intervals)
guard sampleInterval > 0 else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let sampleRate = 1 / sampleInterval
let minimumLag = max(Int((sampleRate / maximumFrequencyHz).rounded(.down)), 2)
let maximumLag = min(Int((sampleRate / minimumFrequencyHz).rounded(.up)), signal.count / 2)
guard maximumLag > minimumLag else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let validValues = signal.indices.compactMap {
validityMask[$0] && signal[$0].isFinite ? signal[$0] : nil
}
let center = SignalStatistics.mean(validValues)
var correlations: [Int: Double] = [:]
for lag in minimumLag...maximumLag {
var cross = 0.0
var leftPower = 0.0
var rightPower = 0.0
var pairCount = 0
for index in 0..<(signal.count - lag) {
guard validityMask[index], validityMask[index + lag] else { continue }
let left = signal[index] - center
let right = signal[index + lag] - center
guard left.isFinite, right.isFinite else { continue }
cross += left * right
leftPower += left * left
rightPower += right * right
pairCount += 1
}
guard pairCount >= 10, leftPower > 1e-12, rightPower > 1e-12 else { continue }
correlations[lag] = cross / (leftPower * rightPower).squareRoot()
}
guard !correlations.isEmpty,
let strongest = correlations.max(by: { $0.value < $1.value }),
strongest.value > 0.10
else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0)
}
let localPeaks = correlations.keys.sorted().filter { lag in
let value = correlations[lag] ?? -.infinity
let left = correlations[lag - 1] ?? -.infinity
let right = correlations[lag + 1] ?? -.infinity
return value >= left && value >= right
}
let strongThreshold = max(strongest.value * 0.92, 0.10)
let chosenLag = localPeaks.first(where: { (correlations[$0] ?? 0) >= strongThreshold })
?? strongest.key
let centerCorrelation = correlations[chosenLag] ?? strongest.value
let leftCorrelation = correlations[chosenLag - 1] ?? centerCorrelation
let rightCorrelation = correlations[chosenLag + 1] ?? centerCorrelation
let denominator = leftCorrelation - 2 * centerCorrelation + rightCorrelation
let fractionalOffset: Double
if abs(denominator) > 1e-12 {
fractionalOffset = min(
max(0.5 * (leftCorrelation - rightCorrelation) / denominator, -0.5),
0.5
)
} else {
fractionalOffset = 0
}
let refinedLag = Double(chosenLag) + fractionalOffset
guard refinedLag > 0 else {
return FrequencyBPMEstimate(bpm: nil, autocorrelation: centerCorrelation, confidence: 0)
}
let bpm = 60 * sampleRate / refinedLag
let confidence = SignalStatistics.clamp01((centerCorrelation - 0.10) / 0.75)
return FrequencyBPMEstimate(
bpm: bpm.isFinite ? bpm : nil,
autocorrelation: centerCorrelation,
confidence: confidence
)
}
}
@@ -0,0 +1,172 @@
import Foundation
public enum PPGSignalPreprocessor {
/// Robustly clips isolated outliers, subtracts a centered local baseline, and scales by MAD.
public static func detrendAndNormalize(
_ input: [Double],
sampleRateHz: Double,
baselineWindowSeconds: Double = 1.6
) -> [Double] {
guard !input.isEmpty, sampleRateHz.isFinite, sampleRateHz > 0 else { return [] }
let finite = sanitize(input)
let center = SignalStatistics.median(finite)
let rawMAD = SignalStatistics.mad(finite, around: center)
let fallbackScale = max(SignalStatistics.standardDeviation(finite), 1e-9)
let clipScale = max(1.4826 * rawMAD, fallbackScale * 0.25, 1e-9)
let clipped = finite.map { min(max($0, center - 8 * clipScale), center + 8 * clipScale) }
var window = max(Int((baselineWindowSeconds * sampleRateHz).rounded()), 3)
if window.isMultiple(of: 2) { window += 1 }
let baseline = centeredMovingAverage(clipped, window: window)
let detrended = zip(clipped, baseline).map { $0.0 - $0.1 }
let detrendedCenter = SignalStatistics.median(detrended)
let detrendedMAD = SignalStatistics.mad(detrended, around: detrendedCenter)
let detrendedSTD = SignalStatistics.standardDeviation(detrended)
let scale = max(1.4826 * detrendedMAD, detrendedSTD * 0.20, 1e-9)
return detrended.map { value in
min(max((value - detrendedCenter) / scale, -12), 12)
}
}
public static func centeredMovingAverage(_ input: [Double], window: Int) -> [Double] {
guard !input.isEmpty else { return [] }
let normalizedWindow = max(window, 1)
let half = normalizedWindow / 2
return input.indices.map { index in
var sum = 0.0
for offset in -half...half {
sum += input[SignalStatistics.reflectedIndex(index + offset, count: input.count)]
}
return sum / Double(2 * half + 1)
}
}
public static func dilateInvalidRegions(_ mask: [Bool], radius: Int) -> [Bool] {
guard radius > 0, !mask.isEmpty else { return mask }
var difference = Array(repeating: 0, count: mask.count + 1)
for index in mask.indices where !mask[index] {
let lower = max(0, index - radius)
let upper = min(mask.count - 1, index + radius)
difference[lower] += 1
difference[upper + 1] -= 1
}
var active = 0
return mask.indices.map { index in
active += difference[index]
return active == 0
}
}
private static func sanitize(_ input: [Double]) -> [Double] {
var lastFinite = input.first(where: \.isFinite) ?? 0
return input.map { value in
if value.isFinite { lastFinite = value }
return lastFinite
}
}
}
/// Symmetric, windowed-sinc FIR band-pass filter. Coefficients are regenerated from the actual
/// resampling frequency, so a 30 Hz and a 60 Hz capture use the same physiological pass band.
public struct PPGBandPassFilter: Equatable, Sendable {
public let sampleRateHz: Double
public let lowCutoffHz: Double
public let highCutoffHz: Double
public let coefficients: [Double]
public init(
sampleRateHz: Double,
lowCutoffHz: Double,
highCutoffHz: Double,
tapCount requestedTapCount: Int? = nil
) throws {
guard sampleRateHz.isFinite,
lowCutoffHz.isFinite,
highCutoffHz.isFinite,
sampleRateHz >= 10,
lowCutoffHz > 0,
highCutoffHz > lowCutoffHz,
highCutoffHz < sampleRateHz / 2
else {
throw PPGSignalError.invalidConfiguration("band-pass frequencies must lie inside Nyquist")
}
var tapCount = requestedTapCount
?? min(max(Int((sampleRateHz * 4).rounded()), 31), 301)
tapCount = max(tapCount, 5)
if tapCount.isMultiple(of: 2) { tapCount += 1 }
self.sampleRateHz = sampleRateHz
self.lowCutoffHz = lowCutoffHz
self.highCutoffHz = highCutoffHz
self.coefficients = Self.makeCoefficients(
sampleRateHz: sampleRateHz,
lowCutoffHz: lowCutoffHz,
highCutoffHz: highCutoffHz,
tapCount: tapCount
)
}
public var invalidRegionGuardRadius: Int { coefficients.count / 2 }
/// Applies centered convolution. The symmetric impulse response introduces no peak-time shift.
public func apply(to input: [Double]) -> [Double] {
guard !input.isEmpty else { return [] }
let clean = input.map { $0.isFinite ? $0 : 0 }
let half = coefficients.count / 2
var output = Array(repeating: 0.0, count: clean.count)
for outputIndex in clean.indices {
var accumulator = 0.0
for coefficientIndex in coefficients.indices {
let sourceIndex = SignalStatistics.reflectedIndex(
outputIndex + coefficientIndex - half,
count: clean.count
)
accumulator += coefficients[coefficientIndex] * clean[sourceIndex]
}
output[outputIndex] = accumulator.isFinite ? accumulator : 0
}
return output
}
private static func makeCoefficients(
sampleRateHz: Double,
lowCutoffHz: Double,
highCutoffHz: Double,
tapCount: Int
) -> [Double] {
let midpoint = tapCount / 2
let low = lowCutoffHz / sampleRateHz
let high = highCutoffHz / sampleRateHz
var taps = (0..<tapCount).map { index -> Double in
let offset = Double(index - midpoint)
let idealHigh = 2 * high * sinc(2 * high * offset)
let idealLow = 2 * low * sinc(2 * low * offset)
let hamming = 0.54 - 0.46 * cos(2 * Double.pi * Double(index) / Double(tapCount - 1))
return (idealHigh - idealLow) * hamming
}
let centerFrequency = (lowCutoffHz * highCutoffHz).squareRoot()
var real = 0.0
var imaginary = 0.0
for index in taps.indices {
let offset = Double(index - midpoint)
let angle = -2 * Double.pi * centerFrequency * offset / sampleRateHz
real += taps[index] * cos(angle)
imaginary += taps[index] * sin(angle)
}
let gain = (real * real + imaginary * imaginary).squareRoot()
if gain > 1e-12 {
taps = taps.map { $0 / gain }
}
return taps
}
private static func sinc(_ value: Double) -> Double {
guard abs(value) > 1e-12 else { return 1 }
return sin(Double.pi * value) / (Double.pi * value)
}
}
@@ -0,0 +1,523 @@
import Foundation
public enum PPGSignalError: Error, Equatable, Sendable {
case invalidConfiguration(String)
case insufficientSamples(required: Int, actual: Int)
case insufficientDuration(requiredSeconds: Double, actualSeconds: Double)
case invalidTimestamps
case noUsableChannel
}
/// Compact numeric output expected from the camera frame extractor.
/// No image data is retained by the signal-processing core.
public struct PPGFrameSample: Codable, Equatable, Sendable {
public var timestampSeconds: Double
public var red: Double
public var green: Double
public var blue: Double
public var luminance: Double
public var contactConfidence: Double
public var saturationRatio: Double
public var motionArtifactScore: Double
public init(
timestampSeconds: Double,
red: Double,
green: Double,
blue: Double? = nil,
luminance: Double,
contactConfidence: Double = 1,
saturationRatio: Double = 0,
motionArtifactScore: Double = 0
) {
self.timestampSeconds = timestampSeconds
self.red = red
self.green = green
let reconstructedBlue = (luminance - 0.2126 * red - 0.7152 * green) / 0.0722
self.blue = blue ?? min(max(reconstructedBlue.isFinite ? reconstructedBlue : luminance, 0), 255)
self.luminance = luminance
self.contactConfidence = Self.clamp01(contactConfidence)
self.saturationRatio = Self.clamp01(saturationRatio)
self.motionArtifactScore = Self.clamp01(motionArtifactScore)
}
public var hasFiniteSignalValues: Bool {
timestampSeconds.isFinite && red.isFinite && green.isFinite
&& blue.isFinite && luminance.isFinite
}
private static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
}
public enum PPGSignalChannel: String, CaseIterable, Codable, Equatable, Sendable {
case red
case green
case blue
case luminance
case greenRedRatio
case blueRedRatio
case normalizedBlend
}
/// Publication gates for the three product decisions made from one camera capture.
///
/// These thresholds deliberately do not mean the same thing:
/// - mean BPM can be published from a technically usable periodic signal;
/// - beat-to-beat PRV needs substantially better timing quality;
/// - the potential-activation index is gated again by the product layer and a
/// personalized baseline. It must never be presented as a medical stress measure.
///
/// Keeping the values named here prevents a permissive live-preview threshold from
/// accidentally becoming the PRV or product-index threshold.
public enum PPGPublicationThresholds {
/// Minimum final technical score for a definitive mean BPM.
public static let bpmQualityScore = 0.45
/// Minimum temporal/frequency estimator confidence for a definitive mean BPM.
public static let bpmConfidence = 0.40
/// Minimum final technical score for beat-to-beat PRV to be marked reliable.
public static let prvQualityScore = 0.80
/// Minimum estimator confidence for beat-to-beat PRV to be marked reliable.
public static let prvBpmConfidence = 0.70
/// Product-layer signal gate used before calculating the potential index.
public static let potentialIndexQualityScore = 0.65
/// Product-layer minimum number of accepted pulse intervals.
public static let potentialIndexMinimumIntervals = 20
}
public struct PPGProcessingConfiguration: Codable, Equatable, Sendable {
public var targetSampleRateHz: Double
public var minimumFrequencyHz: Double
public var maximumFrequencyHz: Double
public var minimumAnalysisDurationSeconds: Double
public var maximumInterpolationGapSeconds: Double
public var longGapLimitSeconds: Double
public var minimumContactConfidence: Double
public var maximumSaturationRatio: Double
public var maximumMotionArtifactScore: Double
public var minimumPeakProminence: Double
public var minimumPeakAmplitude: Double
public var refractoryPeriodSeconds: Double
public var maximumMethodDisagreementBPM: Double
public var minimumQualityScore: Double
public var minimumIntervalsForPRV: Int
public var maximumRejectedIntervalRatioForPRV: Double
public var filterTapCount: Int?
public init(
targetSampleRateHz: Double = 30,
minimumFrequencyHz: Double = 0.7,
maximumFrequencyHz: Double = 3.5,
minimumAnalysisDurationSeconds: Double = 45,
maximumInterpolationGapSeconds: Double = 0.20,
longGapLimitSeconds: Double = 0.80,
minimumContactConfidence: Double = 0.55,
maximumSaturationRatio: Double = 0.70,
maximumMotionArtifactScore: Double = 0.65,
minimumPeakProminence: Double = 0.35,
minimumPeakAmplitude: Double = 0.10,
refractoryPeriodSeconds: Double = 0.25,
maximumMethodDisagreementBPM: Double = 8,
minimumQualityScore: Double = PPGPublicationThresholds.potentialIndexQualityScore,
minimumIntervalsForPRV: Int = 20,
maximumRejectedIntervalRatioForPRV: Double = 0.20,
filterTapCount: Int? = nil
) {
self.targetSampleRateHz = targetSampleRateHz
self.minimumFrequencyHz = minimumFrequencyHz
self.maximumFrequencyHz = maximumFrequencyHz
self.minimumAnalysisDurationSeconds = minimumAnalysisDurationSeconds
self.maximumInterpolationGapSeconds = maximumInterpolationGapSeconds
self.longGapLimitSeconds = longGapLimitSeconds
self.minimumContactConfidence = Self.clamp01(minimumContactConfidence)
self.maximumSaturationRatio = Self.clamp01(maximumSaturationRatio)
self.maximumMotionArtifactScore = Self.clamp01(maximumMotionArtifactScore)
self.minimumPeakProminence = max(minimumPeakProminence, 0)
self.minimumPeakAmplitude = max(minimumPeakAmplitude, 0)
self.refractoryPeriodSeconds = max(refractoryPeriodSeconds, 0)
self.maximumMethodDisagreementBPM = max(maximumMethodDisagreementBPM, 0)
self.minimumQualityScore = Self.clamp01(minimumQualityScore)
self.minimumIntervalsForPRV = max(minimumIntervalsForPRV, 2)
self.maximumRejectedIntervalRatioForPRV = Self.clamp01(maximumRejectedIntervalRatioForPRV)
self.filterTapCount = filterTapCount
}
public static let production = PPGProcessingConfiguration()
public func validated() throws -> Self {
guard targetSampleRateHz.isFinite, targetSampleRateHz >= 10 else {
throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be at least 10 Hz")
}
guard minimumFrequencyHz.isFinite,
maximumFrequencyHz.isFinite,
minimumFrequencyHz > 0,
maximumFrequencyHz > minimumFrequencyHz,
maximumFrequencyHz < targetSampleRateHz / 2
else {
throw PPGSignalError.invalidConfiguration("frequency band must be inside (0, Nyquist)")
}
guard minimumAnalysisDurationSeconds.isFinite, minimumAnalysisDurationSeconds > 0 else {
throw PPGSignalError.invalidConfiguration("minimumAnalysisDurationSeconds must be positive")
}
guard maximumInterpolationGapSeconds.isFinite,
maximumInterpolationGapSeconds > 0,
longGapLimitSeconds.isFinite,
longGapLimitSeconds >= maximumInterpolationGapSeconds
else {
throw PPGSignalError.invalidConfiguration("gap limits are inconsistent")
}
if let filterTapCount, filterTapCount < 5 {
throw PPGSignalError.invalidConfiguration("filterTapCount must be at least 5")
}
return self
}
private static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
}
public struct TimestampedValue: Equatable, Sendable {
public var timestampSeconds: Double
public var value: Double
public init(timestampSeconds: Double, value: Double) {
self.timestampSeconds = timestampSeconds
self.value = value
}
}
public struct TimebaseDiagnostics: Codable, Equatable, Sendable {
public var inputSampleCount: Int
public var usableInputSampleCount: Int
public var discardedSampleCount: Int
public var duplicateTimestampCount: Int
public var outOfOrderTimestampCount: Int
public var durationSeconds: Double
public var usableDurationSeconds: Double
public var estimatedSampleRateHz: Double
public var frameIntervalMADSeconds: Double
public var droppedFrameRatio: Double
public var maximumGapSeconds: Double
public var longGapDurationSeconds: Double
public init(
inputSampleCount: Int,
usableInputSampleCount: Int,
discardedSampleCount: Int,
duplicateTimestampCount: Int,
outOfOrderTimestampCount: Int,
durationSeconds: Double,
usableDurationSeconds: Double,
estimatedSampleRateHz: Double,
frameIntervalMADSeconds: Double,
droppedFrameRatio: Double,
maximumGapSeconds: Double,
longGapDurationSeconds: Double
) {
self.inputSampleCount = inputSampleCount
self.usableInputSampleCount = usableInputSampleCount
self.discardedSampleCount = discardedSampleCount
self.duplicateTimestampCount = duplicateTimestampCount
self.outOfOrderTimestampCount = outOfOrderTimestampCount
self.durationSeconds = max(durationSeconds, 0)
self.usableDurationSeconds = max(usableDurationSeconds, 0)
self.estimatedSampleRateHz = max(estimatedSampleRateHz, 0)
self.frameIntervalMADSeconds = max(frameIntervalMADSeconds, 0)
self.droppedFrameRatio = min(max(droppedFrameRatio, 0), 1)
self.maximumGapSeconds = max(maximumGapSeconds, 0)
self.longGapDurationSeconds = max(longGapDurationSeconds, 0)
}
}
public struct ResampledSignal: Equatable, Sendable {
public var timestampsSeconds: [Double]
public var values: [Double]
public var validityMask: [Bool]
public var diagnostics: TimebaseDiagnostics
public init(
timestampsSeconds: [Double],
values: [Double],
validityMask: [Bool],
diagnostics: TimebaseDiagnostics
) {
precondition(timestampsSeconds.count == values.count)
precondition(values.count == validityMask.count)
self.timestampsSeconds = timestampsSeconds
self.values = values
self.validityMask = validityMask
self.diagnostics = diagnostics
}
}
public struct DetectedPeak: Codable, Equatable, Sendable {
public var sampleIndex: Int
public var timestampSeconds: Double
public var amplitude: Double
public var prominence: Double
public init(sampleIndex: Int, timestampSeconds: Double, amplitude: Double, prominence: Double) {
self.sampleIndex = sampleIndex
self.timestampSeconds = timestampSeconds
self.amplitude = amplitude
self.prominence = max(prominence, 0)
}
}
public enum PulseIntervalRejectionReason: String, Codable, Equatable, Sendable {
case outsidePhysiologicalRange
case localOutlier
case invalidSignalRegion
case lowMorphologyQuality
}
public struct PulseInterval: Codable, Equatable, Sendable {
public var sourceIndex: Int
public var startSeconds: Double
public var endSeconds: Double
public var durationMilliseconds: Double
public var rejectionReason: PulseIntervalRejectionReason?
/// Per-beat technical quality. This is not a medical confidence score.
public var qualityScore: Double
public init(
sourceIndex: Int,
startSeconds: Double,
endSeconds: Double,
durationMilliseconds: Double,
rejectionReason: PulseIntervalRejectionReason? = nil,
qualityScore: Double = 1
) {
self.sourceIndex = sourceIndex
self.startSeconds = startSeconds
self.endSeconds = endSeconds
self.durationMilliseconds = durationMilliseconds
self.rejectionReason = rejectionReason
self.qualityScore = min(max(qualityScore.isFinite ? qualityScore : 0, 0), 1)
}
public var isAccepted: Bool { rejectionReason == nil }
}
public struct PeakDetectionResult: Equatable, Sendable {
public var peaks: [DetectedPeak]
public var intervals: [PulseInterval]
public var selectedPolarity: Int
public var confidence: Double
public init(
peaks: [DetectedPeak],
intervals: [PulseInterval],
selectedPolarity: Int,
confidence: Double
) {
self.peaks = peaks
self.intervals = intervals
self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1
self.confidence = Self.clamp01(confidence)
}
public var acceptedIntervals: [PulseInterval] { intervals.filter(\.isAccepted) }
public var rejectedIntervalCount: Int { intervals.count - acceptedIntervals.count }
private static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
}
public struct BPMEstimate: Codable, Equatable, Sendable {
/// Final BPM. It is nil when the independent temporal and frequency estimates disagree.
public var bpm: Double?
public var temporalBpm: Double?
public var frequencyBpm: Double?
public var methodAgreementBpm: Double?
public var validPeakCount: Int
public var rejectedPeakCount: Int
public var confidence: Double
public var selectedChannel: PPGSignalChannel
public var selectedPolarity: Int
public init(
bpm: Double?,
temporalBpm: Double?,
frequencyBpm: Double?,
methodAgreementBpm: Double?,
validPeakCount: Int,
rejectedPeakCount: Int,
confidence: Double,
selectedChannel: PPGSignalChannel,
selectedPolarity: Int
) {
self.bpm = bpm
self.temporalBpm = temporalBpm
self.frequencyBpm = frequencyBpm
self.methodAgreementBpm = methodAgreementBpm
self.validPeakCount = max(validPeakCount, 0)
self.rejectedPeakCount = max(rejectedPeakCount, 0)
self.confidence = Self.clamp01(confidence)
self.selectedChannel = selectedChannel
self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1
}
private static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
}
public struct PRVMetrics: Codable, Equatable, Sendable {
public var estimatedRmssdMs: Double?
public var estimatedSdnnMs: Double?
public var meanIpiMs: Double?
public var medianIpiMs: Double?
public var pnn50Percent: Double?
public var rangeIpiMs: Double?
public var modeIpiMs: Double?
public var amo50Percent: Double?
public var coefficientOfVariation: Double?
public var validIntervalCount: Int
public var rejectedIntervalCount: Int
public var rejectedIntervalRatio: Double
public var reliable: Bool
public init(
estimatedRmssdMs: Double?,
estimatedSdnnMs: Double?,
meanIpiMs: Double?,
medianIpiMs: Double?,
pnn50Percent: Double?,
rangeIpiMs: Double?,
modeIpiMs: Double?,
amo50Percent: Double?,
coefficientOfVariation: Double?,
validIntervalCount: Int,
rejectedIntervalCount: Int,
rejectedIntervalRatio: Double,
reliable: Bool
) {
self.estimatedRmssdMs = estimatedRmssdMs
self.estimatedSdnnMs = estimatedSdnnMs
self.meanIpiMs = meanIpiMs
self.medianIpiMs = medianIpiMs
self.pnn50Percent = pnn50Percent
self.rangeIpiMs = rangeIpiMs
self.modeIpiMs = modeIpiMs
self.amo50Percent = amo50Percent
self.coefficientOfVariation = coefficientOfVariation
self.validIntervalCount = max(validIntervalCount, 0)
self.rejectedIntervalCount = max(rejectedIntervalCount, 0)
self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio)
self.reliable = reliable
}
private static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 1 }
return min(max(value, 0), 1)
}
}
public enum SignalQualityLevel: String, Codable, Equatable, Sendable {
case good
case medium
case poor
case invalid
}
public enum SignalQualityIssue: String, Codable, Equatable, Hashable, Sendable {
case insufficientDuration
case invalidTimestamps
case longInterruption
case tooManyDroppedFrames
case irregularTimestamps
case poorFingerContact
case excessiveSaturation
case motionArtifacts
case lowPulsatileAmplitude
case lowSignalToNoise
case tooFewPeaks
case inconsistentBPMMethods
case tooManyRejectedIntervals
case unstableChannel
case nonFiniteInput
}
public struct SignalQualityResult: Codable, Equatable, Sendable {
public var score: Double
public var level: SignalQualityLevel
public var usableDurationSeconds: Double
public var droppedFrameRatio: Double
public var saturationRatio: Double
public var rejectedIntervalRatio: Double
/// Signal-to-residual ratio in decibels around the selected pulse frequency.
public var signalToNoiseRatio: Double?
public var motionArtifactRatio: Double
public var methodAgreementScore: Double
public var issues: [SignalQualityIssue]
public init(
score: Double,
level: SignalQualityLevel,
usableDurationSeconds: Double,
droppedFrameRatio: Double,
saturationRatio: Double,
rejectedIntervalRatio: Double,
signalToNoiseRatio: Double?,
motionArtifactRatio: Double,
methodAgreementScore: Double,
issues: [SignalQualityIssue]
) {
self.score = Self.clamp01(score)
self.level = level
self.usableDurationSeconds = max(usableDurationSeconds, 0)
self.droppedFrameRatio = Self.clamp01(droppedFrameRatio)
self.saturationRatio = Self.clamp01(saturationRatio)
self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio)
self.signalToNoiseRatio = signalToNoiseRatio?.isFinite == true ? signalToNoiseRatio : nil
self.motionArtifactRatio = Self.clamp01(motionArtifactRatio)
self.methodAgreementScore = Self.clamp01(methodAgreementScore)
self.issues = Array(Set(issues)).sorted { $0.rawValue < $1.rawValue }
}
private static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
}
public enum PPGAnalysisStatus: String, Codable, Equatable, Sendable {
case valid
case invalid
}
public struct PPGAnalysisResult: Codable, Equatable, Sendable {
public var status: PPGAnalysisStatus
public var bpm: BPMEstimate
public var prv: PRVMetrics
public var quality: SignalQualityResult
public var peaks: [DetectedPeak]
public var intervals: [PulseInterval]
public var timebase: TimebaseDiagnostics
public init(
status: PPGAnalysisStatus,
bpm: BPMEstimate,
prv: PRVMetrics,
quality: SignalQualityResult,
peaks: [DetectedPeak],
intervals: [PulseInterval],
timebase: TimebaseDiagnostics
) {
self.status = status
self.bpm = bpm
self.prv = prv
self.quality = quality
self.peaks = peaks
self.intervals = intervals
self.timebase = timebase
}
}
@@ -0,0 +1,60 @@
import Foundation
public enum PPGCoherencePolicy {
public static let spotMaximumDurationSeconds = 120.0
public static let coherenceMaximumDurationSeconds = 300.0
public static let coherenceBufferDurationSeconds = 75.0
public static func maximumDurationSeconds(isCoherence: Bool) -> Double {
isCoherence ? coherenceMaximumDurationSeconds : spotMaximumDurationSeconds
}
}
public struct PPGLiveAcceptedBeat: Equatable, Sendable {
public let sequence: Int
public let timestampSeconds: Double
public let intervalMs: Double
public let confidence: Double
}
/// Deduplicates accepted intervals emitted by overlapping live-analysis windows.
public struct PPGLiveBeatTracker: Sendable {
private var lastPublishedTimestamp: Double?
private var sequence = 0
public init() {}
public mutating func ingest(_ intervals: [PulseInterval]) -> [PPGLiveAcceptedBeat] {
let sorted = intervals
.filter {
$0.isAccepted && $0.endSeconds.isFinite && $0.durationMilliseconds.isFinite
&& (300...2_000).contains($0.durationMilliseconds)
}
.sorted { $0.endSeconds < $1.endSeconds }
let previous = lastPublishedTimestamp
let newIntervals = sorted.filter { interval in
guard let previous else { return true }
// Physiological intervals are at least 300 ms. A 250 ms boundary treats
// small peak refinements as the same beat without swallowing a real one.
return interval.endSeconds > previous + 0.25
}
let beats = newIntervals.map { interval -> PPGLiveAcceptedBeat in
sequence += 1
return PPGLiveAcceptedBeat(
sequence: sequence,
timestampSeconds: interval.endSeconds,
intervalMs: interval.durationMilliseconds,
confidence: min(max(interval.qualityScore, 0), 1)
)
}
if let latest = sorted.last?.endSeconds {
lastPublishedTimestamp = max(lastPublishedTimestamp ?? latest, latest)
}
return beats
}
public mutating func reset() {
lastPublishedTimestamp = nil
sequence = 0
}
}
@@ -0,0 +1,242 @@
import Foundation
/// A deterministic artifact window used by ``PPGSyntheticSignalConfiguration``.
public struct PPGSyntheticArtifactWindow: Codable, Equatable, Sendable {
public var startSeconds: Double
public var endSeconds: Double
public var intensity: Double
public init(startSeconds: Double, endSeconds: Double, intensity: Double = 1) {
self.startSeconds = max(startSeconds.isFinite ? startSeconds : 0, 0)
self.endSeconds = max(endSeconds.isFinite ? endSeconds : 0, self.startSeconds)
self.intensity = min(max(intensity.isFinite ? intensity : 0, 0), 1)
}
func intensity(at time: Double) -> Double {
(startSeconds...endSeconds).contains(time) ? intensity : 0
}
}
/// Configuration for a numeric PPG camera fixture.
///
/// The generator creates the same compact RGB/luminance/time values consumed by
/// production signal processing. It never creates or retains images. All random
/// noise is seeded, so a failing test can be reproduced byte for byte.
public struct PPGSyntheticSignalConfiguration: Codable, Equatable, Sendable {
public var sampleRateHz: Double
public var durationSeconds: Double
public var startingBPM: Double
public var endingBPM: Double
/// Repeating beat-to-beat offsets, in milliseconds, added to the nominal IPI.
public var intervalVariationMilliseconds: [Double]
public var noiseAmplitude: Double
public var driftAmplitude: Double
public var driftFrequencyHz: Double
public var movementWindows: [PPGSyntheticArtifactWindow]
public var saturationWindows: [PPGSyntheticArtifactWindow]
/// Every nth nominal camera frame is omitted. Nil keeps every frame.
public var dropEveryNthFrame: Int?
/// Suppresses the pulse cycle beginning at each listed zero-based beat index.
public var missingBeatIndices: Set<Int>
/// Adds a narrow artificial pulse at each supplied timestamp.
public var falsePeakTimesSeconds: [Double]
public var seed: UInt64
public init(
sampleRateHz: Double = 30,
durationSeconds: Double = 60,
startingBPM: Double = 75,
endingBPM: Double? = nil,
intervalVariationMilliseconds: [Double] = [],
noiseAmplitude: Double = 0,
driftAmplitude: Double = 0,
driftFrequencyHz: Double = 0.05,
movementWindows: [PPGSyntheticArtifactWindow] = [],
saturationWindows: [PPGSyntheticArtifactWindow] = [],
dropEveryNthFrame: Int? = nil,
missingBeatIndices: Set<Int> = [],
falsePeakTimesSeconds: [Double] = [],
seed: UInt64 = 0x4841_5054_494B_4101
) {
self.sampleRateHz = sampleRateHz
self.durationSeconds = durationSeconds
self.startingBPM = startingBPM
self.endingBPM = endingBPM ?? startingBPM
self.intervalVariationMilliseconds = intervalVariationMilliseconds
self.noiseAmplitude = max(noiseAmplitude, 0)
self.driftAmplitude = max(driftAmplitude, 0)
self.driftFrequencyHz = max(driftFrequencyHz, 0)
self.movementWindows = movementWindows
self.saturationWindows = saturationWindows
self.dropEveryNthFrame = dropEveryNthFrame
self.missingBeatIndices = missingBeatIndices
self.falsePeakTimesSeconds = falsePeakTimesSeconds
self.seed = seed
}
public func validated() throws -> Self {
guard sampleRateHz.isFinite, sampleRateHz >= 10, sampleRateHz <= 240 else {
throw PPGSignalError.invalidConfiguration("synthetic sampleRateHz must be in 10...240")
}
guard durationSeconds.isFinite, durationSeconds > 0 else {
throw PPGSignalError.invalidConfiguration("synthetic durationSeconds must be positive")
}
guard startingBPM.isFinite, endingBPM.isFinite,
(30...240).contains(startingBPM), (30...240).contains(endingBPM)
else {
throw PPGSignalError.invalidConfiguration("synthetic BPM must be in 30...240")
}
guard intervalVariationMilliseconds.allSatisfy({ $0.isFinite && abs($0) <= 300 }) else {
throw PPGSignalError.invalidConfiguration("synthetic interval variation must be finite and <= 300 ms")
}
if let dropEveryNthFrame, dropEveryNthFrame < 2 {
throw PPGSignalError.invalidConfiguration("dropEveryNthFrame must be at least 2")
}
return self
}
}
public struct PPGSyntheticSignal: Equatable, Sendable {
public var samples: [PPGFrameSample]
public var beatTimesSeconds: [Double]
public var nominalIntervalsMilliseconds: [Double]
public init(
samples: [PPGFrameSample],
beatTimesSeconds: [Double],
nominalIntervalsMilliseconds: [Double]
) {
self.samples = samples
self.beatTimesSeconds = beatTimesSeconds
self.nominalIntervalsMilliseconds = nominalIntervalsMilliseconds
}
}
public enum PPGSyntheticSignalGenerator {
public static func generate(
configuration input: PPGSyntheticSignalConfiguration
) throws -> PPGSyntheticSignal {
let configuration = try input.validated()
let beatSchedule = makeBeatSchedule(configuration)
let frameCount = Int((configuration.durationSeconds * configuration.sampleRateHz).rounded(.down)) + 1
var random = SeededRandomNumberGenerator(seed: configuration.seed)
var samples: [PPGFrameSample] = []
samples.reserveCapacity(frameCount)
var beatIndex = 0
for frameIndex in 0..<frameCount {
if let nth = configuration.dropEveryNthFrame,
frameIndex > 0, frameIndex.isMultiple(of: nth) {
continue
}
let time = Double(frameIndex) / configuration.sampleRateHz
while beatIndex + 1 < beatSchedule.times.count,
beatSchedule.times[beatIndex + 1] <= time {
beatIndex += 1
}
let currentBeat = beatSchedule.times[min(beatIndex, beatSchedule.times.count - 1)]
let nextBeat = beatIndex + 1 < beatSchedule.times.count
? beatSchedule.times[beatIndex + 1]
: currentBeat + 60 / configuration.endingBPM
let phase = min(max((time - currentBeat) / max(nextBeat - currentBeat, 1e-6), 0), 1)
var pulse = cos(2 * Double.pi * phase) + 0.18 * cos(4 * Double.pi * phase)
if configuration.missingBeatIndices.contains(beatIndex) { pulse = 0 }
for falsePeakTime in configuration.falsePeakTimesSeconds where falsePeakTime.isFinite {
let distance = (time - falsePeakTime) / 0.055
pulse += 1.25 * exp(-0.5 * distance * distance)
}
let drift = configuration.driftAmplitude
* sin(2 * Double.pi * configuration.driftFrequencyHz * time)
let movement = configuration.movementWindows.map { $0.intensity(at: time) }.max() ?? 0
let saturation = configuration.saturationWindows.map { $0.intensity(at: time) }.max() ?? 0
let baseNoise = random.nextGaussian() * configuration.noiseAmplitude
let movementNoise = random.nextGaussian() * movement * 16
let commonLight = 1 + drift + movementNoise * 0.006
var red = 176 * commonLight + 2.8 * pulse + 0.55 * baseNoise + movementNoise
var green = 92 * commonLight + 6.2 * pulse + baseNoise - 0.35 * movementNoise
var blue = 68 * commonLight + 1.8 * pulse + 0.45 * baseNoise + 0.20 * movementNoise
if saturation > 0 {
red = red * (1 - saturation) + 255 * saturation
green = green * (1 - saturation) + 255 * saturation
blue = blue * (1 - saturation) + 230 * saturation
}
red = min(max(red, 0), 255)
green = min(max(green, 0), 255)
blue = min(max(blue, 0), 255)
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
samples.append(
PPGFrameSample(
timestampSeconds: time,
red: red,
green: green,
blue: blue,
luminance: luminance,
contactConfidence: max(0.95 - 0.25 * movement, 0),
saturationRatio: saturation,
motionArtifactScore: movement
)
)
}
return PPGSyntheticSignal(
samples: samples,
beatTimesSeconds: beatSchedule.times,
nominalIntervalsMilliseconds: beatSchedule.intervals
)
}
private static func makeBeatSchedule(
_ configuration: PPGSyntheticSignalConfiguration
) -> (times: [Double], intervals: [Double]) {
var times = [0.0]
var intervals: [Double] = []
var beatIndex = 0
while let last = times.last, last < configuration.durationSeconds {
let progress = min(max(last / configuration.durationSeconds, 0), 1)
let bpm = configuration.startingBPM
+ (configuration.endingBPM - configuration.startingBPM) * progress
let variation = configuration.intervalVariationMilliseconds.isEmpty
? 0
: configuration.intervalVariationMilliseconds[
beatIndex % configuration.intervalVariationMilliseconds.count
]
let intervalMs = max(60_000 / bpm + variation, 250)
intervals.append(intervalMs)
times.append(last + intervalMs / 1_000)
beatIndex += 1
}
return (times, intervals)
}
}
private struct SeededRandomNumberGenerator: RandomNumberGenerator {
private var state: UInt64
private var spareGaussian: Double?
init(seed: UInt64) {
state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
return value ^ (value >> 31)
}
mutating func nextGaussian() -> Double {
if let spareGaussian {
self.spareGaussian = nil
return spareGaussian
}
let u1 = max(Double(next()) / Double(UInt64.max), 1e-12)
let u2 = Double(next()) / Double(UInt64.max)
let radius = sqrt(-2 * log(u1))
let angle = 2 * Double.pi * u2
spareGaussian = radius * sin(angle)
return radius * cos(angle)
}
}
@@ -0,0 +1,143 @@
import Foundation
public struct PRVCalculationResult: Equatable, Sendable {
public var metrics: PRVMetrics
public var intervals: [PulseInterval]
public init(metrics: PRVMetrics, intervals: [PulseInterval]) {
self.metrics = metrics
self.intervals = intervals
}
}
public enum PPGPRVCalculator {
public static func calculate(
intervals inputIntervals: [PulseInterval],
configuration: PPGProcessingConfiguration
) throws -> PRVCalculationResult {
let configuration = try configuration.validated()
var intervals = inputIntervals
let minimumInterval = 1_000 / configuration.maximumFrequencyHz
let maximumInterval = 1_000 / configuration.minimumFrequencyHz
for index in intervals.indices where intervals[index].rejectionReason == nil {
let value = intervals[index].durationMilliseconds
if !value.isFinite || value < minimumInterval || value > maximumInterval {
intervals[index].rejectionReason = .outsidePhysiologicalRange
}
}
// Filter against a moving neighbourhood (±13 intervals), not one global
// median. This follows gradual physiological changes while isolating a
// missed/double peak or a short movement artifact. Decisions use a frozen
// snapshot so one rejected beat cannot cascade into later rejections.
let initiallyAccepted = Set(intervals.indices.filter { intervals[$0].isAccepted })
if initiallyAccepted.count >= 3 {
for index in intervals.indices where initiallyAccepted.contains(index) {
let lower = max(0, index - 13)
let upper = min(intervals.count - 1, index + 13)
let localValues = (lower...upper).compactMap { neighbour -> Double? in
guard neighbour != index, initiallyAccepted.contains(neighbour) else { return nil }
return intervals[neighbour].durationMilliseconds
}
guard localValues.count >= 2 else { continue }
let center = SignalStatistics.median(localValues)
let robustDeviation = 1.4826 * SignalStatistics.mad(localValues, around: center)
let allowedDeviation = max(center * 0.20, 3.5 * robustDeviation, 70)
let value = intervals[index].durationMilliseconds
let resemblesMissedBeat = abs(value - 2 * center) <= max(0.18 * center, 70)
let resemblesDoublePeak = abs(2 * value - center) <= max(0.18 * center, 70)
if abs(value - center) > allowedDeviation
|| resemblesMissedBeat
|| resemblesDoublePeak {
intervals[index].rejectionReason = .localOutlier
}
}
}
let accepted = intervals.filter(\.isAccepted)
let values = accepted.map(\.durationMilliseconds)
let rejectedCount = intervals.count - accepted.count
let rejectedRatio = intervals.isEmpty
? 1
: Double(rejectedCount) / Double(intervals.count)
let median = values.isEmpty ? nil : SignalStatistics.median(values)
let mean = values.isEmpty ? nil : SignalStatistics.mean(values)
let sdnn = values.count >= 2 ? SignalStatistics.standardDeviation(values) : nil
let coefficientOfVariation: Double?
if let median, let sdnn, median > 0 {
coefficientOfVariation = sdnn / median
} else {
coefficientOfVariation = nil
}
var successiveDifferences: [Double] = []
for pair in zip(accepted, accepted.dropFirst()) {
// Do not bridge over a rejected or missing beat: it would create a fictitious PRV difference.
guard pair.1.sourceIndex == pair.0.sourceIndex + 1 else { continue }
let difference = pair.1.durationMilliseconds - pair.0.durationMilliseconds
successiveDifferences.append(difference)
}
let squaredSuccessiveDifferences = successiveDifferences.map { $0 * $0 }
let rmssd = squaredSuccessiveDifferences.isEmpty
? nil
: SignalStatistics.mean(squaredSuccessiveDifferences).squareRoot()
let pnn50Percent = successiveDifferences.isEmpty
? nil
: 100 * Double(successiveDifferences.filter { abs($0) > 50 }.count)
/ Double(successiveDifferences.count)
let rangeIpiMs: Double? = values.count >= 2
? (values.max() ?? 0) - (values.min() ?? 0)
: nil
let histogram = Dictionary(grouping: values) { Int(floor($0 / 50)) }
let modalBin = histogram.max { lhs, rhs in
if lhs.value.count == rhs.value.count { return lhs.key > rhs.key }
return lhs.value.count < rhs.value.count
}
let modeIpiMs = modalBin.map { SignalStatistics.mean($0.value) }
let amo50Percent = modalBin.map {
100 * Double($0.value.count) / Double(values.count)
}
let reliable = values.count >= configuration.minimumIntervalsForPRV
&& rejectedRatio <= configuration.maximumRejectedIntervalRatioForPRV
&& rmssd != nil
&& sdnn != nil
let metrics = PRVMetrics(
estimatedRmssdMs: rmssd,
estimatedSdnnMs: sdnn,
meanIpiMs: mean,
medianIpiMs: median,
pnn50Percent: pnn50Percent,
rangeIpiMs: rangeIpiMs,
modeIpiMs: modeIpiMs,
amo50Percent: amo50Percent,
coefficientOfVariation: coefficientOfVariation,
validIntervalCount: values.count,
rejectedIntervalCount: rejectedCount,
rejectedIntervalRatio: rejectedRatio,
reliable: reliable
)
return PRVCalculationResult(metrics: metrics, intervals: intervals)
}
public static func calculate(
intervalsMilliseconds: [Double],
configuration: PPGProcessingConfiguration
) throws -> PRVMetrics {
var elapsed = 0.0
let intervals = intervalsMilliseconds.enumerated().map { index, duration in
let start = elapsed
elapsed += duration / 1_000
return PulseInterval(
sourceIndex: index,
startSeconds: start,
endSeconds: elapsed,
durationMilliseconds: duration
)
}
return try calculate(intervals: intervals, configuration: configuration).metrics
}
}
@@ -0,0 +1,276 @@
import Foundation
public enum PPGPeakDetector {
public static func detect(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
configuration: PPGProcessingConfiguration
) throws -> PeakDetectionResult {
let configuration = try configuration.validated()
guard signal.count == timestampsSeconds.count,
signal.count == validityMask.count,
signal.count >= 5
else {
throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count)
}
let positive = detectPolarity(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
polarity: 1,
configuration: configuration
)
let negative = detectPolarity(
signal: signal,
timestampsSeconds: timestampsSeconds,
validityMask: validityMask,
polarity: -1,
configuration: configuration
)
if negative.confidence > positive.confidence + 1e-12 {
return negative
}
if abs(negative.confidence - positive.confidence) <= 1e-12,
negative.acceptedIntervals.count > positive.acceptedIntervals.count {
return negative
}
return positive
}
private struct Candidate {
var index: Int
var amplitude: Double
var prominence: Double
}
private static func detectPolarity(
signal: [Double],
timestampsSeconds: [Double],
validityMask: [Bool],
polarity: Int,
configuration: PPGProcessingConfiguration
) -> PeakDetectionResult {
let values = signal.map { ($0.isFinite ? $0 : 0) * Double(polarity) }
let timestampIntervals = zip(timestampsSeconds, timestampsSeconds.dropFirst())
.map { $1 - $0 }
.filter { $0.isFinite && $0 > 0 }
let sampleInterval = SignalStatistics.median(timestampIntervals)
guard sampleInterval > 0 else {
return PeakDetectionResult(peaks: [], intervals: [], selectedPolarity: polarity, confidence: 0)
}
let sampleRate = 1 / sampleInterval
let validValues = values.indices.compactMap { validityMask[$0] ? values[$0] : nil }
let center = SignalStatistics.median(validValues)
let robustScale = max(1.4826 * SignalStatistics.mad(validValues, around: center), 1e-6)
let amplitudeThreshold = center + max(configuration.minimumPeakAmplitude, robustScale * 0.18)
let prominenceRadius = max(Int((0.45 * sampleRate).rounded()), 2)
var candidates: [Candidate] = []
candidates.reserveCapacity(max(signal.count / 15, 8))
for index in 1..<(values.count - 1) {
guard validityMask[index],
validityMask[index - 1],
validityMask[index + 1],
values[index] >= amplitudeThreshold,
values[index] > values[index - 1],
values[index] >= values[index + 1]
else { continue }
let leftStart = max(0, index - prominenceRadius)
let rightEnd = min(values.count - 1, index + prominenceRadius)
let leftMinimum = values[leftStart..<index].min() ?? values[index]
let rightMinimum = values[(index + 1)...rightEnd].min() ?? values[index]
let prominence = min(values[index] - leftMinimum, values[index] - rightMinimum)
guard prominence >= configuration.minimumPeakProminence else { continue }
candidates.append(Candidate(index: index, amplitude: values[index], prominence: prominence))
}
let minimumDistance = max(
Int((configuration.refractoryPeriodSeconds * sampleRate).rounded()),
2
)
var selected: [Candidate] = []
for candidate in candidates {
if let last = selected.last, candidate.index - last.index < minimumDistance {
let candidateStrength = candidate.prominence + 0.25 * candidate.amplitude
let lastStrength = last.prominence + 0.25 * last.amplitude
if candidateStrength > lastStrength {
selected[selected.count - 1] = candidate
}
} else {
selected.append(candidate)
}
}
let peaks = selected.map { candidate -> DetectedPeak in
let index = candidate.index
let left = values[index - 1]
let middle = values[index]
let right = values[index + 1]
let denominator = left - 2 * middle + right
let fractionalOffset: Double
if abs(denominator) > 1e-12 {
fractionalOffset = min(max(0.5 * (left - right) / denominator, -0.5), 0.5)
} else {
fractionalOffset = 0
}
let refinedTimestamp = timestampsSeconds[index] + fractionalOffset * sampleInterval
return DetectedPeak(
sampleIndex: index,
timestampSeconds: refinedTimestamp,
amplitude: candidate.amplitude,
prominence: candidate.prominence
)
}
var intervals = zip(peaks, peaks.dropFirst()).enumerated().map { sourceIndex, pair in
let durationMilliseconds = (pair.1.timestampSeconds - pair.0.timestampSeconds) * 1_000
let lowerIndex = min(pair.0.sampleIndex, pair.1.sampleIndex)
let upperIndex = max(pair.0.sampleIndex, pair.1.sampleIndex)
let crossesInvalidRegion = validityMask[lowerIndex...upperIndex].contains(false)
return PulseInterval(
sourceIndex: sourceIndex,
startSeconds: pair.0.timestampSeconds,
endSeconds: pair.1.timestampSeconds,
durationMilliseconds: durationMilliseconds,
rejectionReason: crossesInvalidRegion ? .invalidSignalRegion : nil
)
}
let minimumInterval = 1_000 / configuration.maximumFrequencyHz
let maximumInterval = 1_000 / configuration.minimumFrequencyHz
for index in intervals.indices where intervals[index].rejectionReason == nil {
let duration = intervals[index].durationMilliseconds
if !duration.isFinite || duration < minimumInterval || duration > maximumInterval {
intervals[index].rejectionReason = .outsidePhysiologicalRange
}
}
// Score every pulse independently. A short movement or pressure change can
// therefore reject only the affected beats instead of invalidating the
// complete recording. Cycles are normalized to 50 points before their
// shape is compared with neighbouring cycles, following the published
// morphology-based quality approach used for camera PPG.
let cycleShapes = intervals.indices.map { index in
normalizedCycle(
values: values,
lowerIndex: peaks[index].sampleIndex,
upperIndex: peaks[index + 1].sampleIndex,
outputCount: 50
)
}
let medianProminence = max(SignalStatistics.median(peaks.map(\.prominence)), 1e-9)
for index in intervals.indices {
let lowerPeakQuality = SignalStatistics.clamp01(peaks[index].prominence / medianProminence)
let upperPeakQuality = SignalStatistics.clamp01(peaks[index + 1].prominence / medianProminence)
let prominenceQuality = min(lowerPeakQuality, upperPeakQuality)
let neighbourhood = max(0, index - 3)...min(intervals.count - 1, index + 3)
let correlations = neighbourhood.compactMap { neighbour -> Double? in
guard neighbour != index,
let shape = cycleShapes[index],
let other = cycleShapes[neighbour]
else { return nil }
return shapeCorrelation(shape, other)
}
let morphologyQuality: Double
if correlations.isEmpty {
morphologyQuality = intervals.count < 3 ? 0.75 : 0
} else {
// Use the best two local matches so one adjacent corrupted cycle does
// not drag a clean beat below the acceptance threshold.
let strongest = correlations.sorted(by: >).prefix(2)
morphologyQuality = SignalStatistics.clamp01(
(SignalStatistics.mean(Array(strongest)) + 1) / 2
)
}
let technicalQuality = SignalStatistics.clamp01(
0.65 * morphologyQuality + 0.35 * prominenceQuality
)
intervals[index].qualityScore = technicalQuality
if intervals[index].rejectionReason == nil,
intervals.count >= 3,
technicalQuality < 0.42 {
intervals[index].rejectionReason = .lowMorphologyQuality
}
}
let physiologicallyPlausible = intervals
.filter(\.isAccepted)
.map(\.durationMilliseconds)
if physiologicallyPlausible.count >= 3 {
let median = SignalStatistics.median(physiologicallyPlausible)
let robustDeviation = 1.4826 * SignalStatistics.mad(physiologicallyPlausible, around: median)
let allowedDeviation = max(0.28 * median, 3.5 * robustDeviation, 90)
for index in intervals.indices where intervals[index].rejectionReason == nil {
if abs(intervals[index].durationMilliseconds - median) > allowedDeviation {
intervals[index].rejectionReason = .localOutlier
}
}
}
let accepted = intervals.filter(\.isAccepted)
let acceptedDurations = accepted.map(\.durationMilliseconds)
let intervalAcceptance = intervals.isEmpty
? 0
: Double(accepted.count) / Double(intervals.count)
let countScore = min(Double(accepted.count) / 8, 1)
let regularity: Double
if acceptedDurations.count >= 2 {
let median = max(SignalStatistics.median(acceptedDurations), 1)
let robustCV = 1.4826 * SignalStatistics.mad(acceptedDurations, around: median) / median
regularity = SignalStatistics.clamp01(1 - robustCV / 0.20)
} else {
regularity = 0
}
let prominenceScore = SignalStatistics.clamp01(medianProminence / 1.5)
let validCoverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1))
let confidence = 0.27 * countScore
+ 0.23 * intervalAcceptance
+ 0.20 * regularity
+ 0.20 * prominenceScore
+ 0.10 * validCoverage
return PeakDetectionResult(
peaks: peaks,
intervals: intervals,
selectedPolarity: polarity,
confidence: confidence
)
}
private static func normalizedCycle(
values: [Double],
lowerIndex: Int,
upperIndex: Int,
outputCount: Int
) -> [Double]? {
guard lowerIndex >= 0,
upperIndex < values.count,
upperIndex - lowerIndex >= 3,
outputCount >= 3
else { return nil }
let span = Double(upperIndex - lowerIndex)
let sampled = (0..<outputCount).map { outputIndex -> Double in
let position = Double(lowerIndex)
+ Double(outputIndex) * span / Double(outputCount - 1)
let left = min(max(Int(position.rounded(.down)), lowerIndex), upperIndex)
let right = min(left + 1, upperIndex)
let fraction = position - Double(left)
return values[left] + fraction * (values[right] - values[left])
}
let center = SignalStatistics.mean(sampled)
let scale = SignalStatistics.standardDeviation(sampled)
guard scale.isFinite, scale > 1e-8 else { return nil }
return sampled.map { ($0 - center) / scale }
}
private static func shapeCorrelation(_ lhs: [Double], _ rhs: [Double]) -> Double? {
guard lhs.count == rhs.count, lhs.count >= 3 else { return nil }
let value = zip(lhs, rhs).reduce(0.0) { $0 + $1.0 * $1.1 } / Double(lhs.count)
return value.isFinite ? min(max(value, -1), 1) : nil
}
}
@@ -0,0 +1,135 @@
import Foundation
public enum PPGSignalResampler {
/// Resamples irregular samples onto a uniform time grid using the real frame timestamps.
/// Values spanning a long gap are numerically interpolated to keep downstream filters finite,
/// but the corresponding validity mask remains false so no beat can be accepted there.
public static func resample(
_ samples: [TimestampedValue],
targetSampleRateHz: Double,
maximumInterpolationGapSeconds: Double
) throws -> ResampledSignal {
guard targetSampleRateHz.isFinite, targetSampleRateHz > 0 else {
throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be positive")
}
guard maximumInterpolationGapSeconds.isFinite, maximumInterpolationGapSeconds > 0 else {
throw PPGSignalError.invalidConfiguration("maximumInterpolationGapSeconds must be positive")
}
let finite = samples.enumerated().compactMap { index, sample -> (Int, TimestampedValue)? in
guard sample.timestampSeconds.isFinite, sample.value.isFinite else { return nil }
return (index, sample)
}
guard finite.count >= 3 else {
throw PPGSignalError.insufficientSamples(required: 3, actual: finite.count)
}
let outOfOrderCount = zip(finite, finite.dropFirst()).reduce(0) { count, pair in
count + (pair.1.1.timestampSeconds < pair.0.1.timestampSeconds ? 1 : 0)
}
let sorted = finite.sorted {
if $0.1.timestampSeconds == $1.1.timestampSeconds { return $0.0 < $1.0 }
return $0.1.timestampSeconds < $1.1.timestampSeconds
}
var unique: [TimestampedValue] = []
unique.reserveCapacity(sorted.count)
var duplicateTimestampCount = 0
for (_, sample) in sorted {
if let last = unique.last, sample.timestampSeconds - last.timestampSeconds <= 1e-9 {
duplicateTimestampCount += 1
continue
}
unique.append(sample)
}
guard unique.count >= 3 else {
throw PPGSignalError.invalidTimestamps
}
let intervals = zip(unique, unique.dropFirst()).map {
$1.timestampSeconds - $0.timestampSeconds
}
guard intervals.allSatisfy({ $0.isFinite && $0 > 0 }) else {
throw PPGSignalError.invalidTimestamps
}
let medianInterval = SignalStatistics.median(intervals)
guard medianInterval > 0 else { throw PPGSignalError.invalidTimestamps }
let intervalMAD = SignalStatistics.mad(intervals, around: medianInterval)
let maximumGap = intervals.max() ?? 0
let duration = unique.last!.timestampSeconds - unique.first!.timestampSeconds
var inferredMissingFrames = 0
var longGapDuration = 0.0
for interval in intervals {
if interval > medianInterval * 1.5 {
inferredMissingFrames += max(Int((interval / medianInterval).rounded()) - 1, 0)
}
if interval > maximumInterpolationGapSeconds {
longGapDuration += max(interval - medianInterval, 0)
}
}
let inferredTotalFrames = unique.count + inferredMissingFrames
let droppedFrameRatio = inferredTotalFrames > 0
? Double(inferredMissingFrames) / Double(inferredTotalFrames)
: 0
let usableDuration = max(duration - longGapDuration, 0)
let step = 1 / targetSampleRateHz
let outputCount = max(Int((duration * targetSampleRateHz).rounded(.down)) + 1, 1)
var timestamps: [Double] = []
var values: [Double] = []
var validityMask: [Bool] = []
timestamps.reserveCapacity(outputCount)
values.reserveCapacity(outputCount)
validityMask.reserveCapacity(outputCount)
var upperIndex = 1
let firstTimestamp = unique[0].timestampSeconds
for outputIndex in 0..<outputCount {
let timestamp = firstTimestamp + Double(outputIndex) * step
while upperIndex < unique.count - 1,
unique[upperIndex].timestampSeconds < timestamp {
upperIndex += 1
}
let upper = unique[upperIndex]
let lower = unique[upperIndex - 1]
let gap = upper.timestampSeconds - lower.timestampSeconds
let fraction = gap > 0 ? (timestamp - lower.timestampSeconds) / gap : 0
let clampedFraction = min(max(fraction, 0), 1)
let value = lower.value + clampedFraction * (upper.value - lower.value)
let liesInsideSourceRange = timestamp >= lower.timestampSeconds - 1e-9
&& timestamp <= upper.timestampSeconds + 1e-9
let isExactSourceSample = abs(timestamp - lower.timestampSeconds) <= step * 1e-6
|| abs(timestamp - upper.timestampSeconds) <= step * 1e-6
let isValid = liesInsideSourceRange
&& (isExactSourceSample || gap <= maximumInterpolationGapSeconds)
timestamps.append(timestamp)
values.append(value.isFinite ? value : 0)
validityMask.append(isValid)
}
let diagnostics = TimebaseDiagnostics(
inputSampleCount: samples.count,
usableInputSampleCount: unique.count,
discardedSampleCount: samples.count - finite.count,
duplicateTimestampCount: duplicateTimestampCount,
outOfOrderTimestampCount: outOfOrderCount,
durationSeconds: duration,
usableDurationSeconds: usableDuration,
estimatedSampleRateHz: 1 / medianInterval,
frameIntervalMADSeconds: intervalMAD,
droppedFrameRatio: droppedFrameRatio,
maximumGapSeconds: maximumGap,
longGapDurationSeconds: longGapDuration
)
return ResampledSignal(
timestampsSeconds: timestamps,
values: values,
validityMask: validityMask,
diagnostics: diagnostics
)
}
}
@@ -0,0 +1,331 @@
import Foundation
public struct PPGSignalProcessor: Sendable {
public let configuration: PPGProcessingConfiguration
public init(configuration: PPGProcessingConfiguration = .production) throws {
self.configuration = try configuration.validated()
}
public func analyze(samples inputSamples: [PPGFrameSample]) throws -> PPGAnalysisResult {
let finiteSamples = inputSamples.filter(\.hasFiniteSignalValues)
let discardedInputCount = inputSamples.count - finiteSamples.count
guard finiteSamples.count >= 3 else {
throw PPGSignalError.insufficientSamples(required: 3, actual: finiteSamples.count)
}
let sortedTimestamps = finiteSamples.map(\.timestampSeconds).sorted()
guard let firstTimestamp = sortedTimestamps.first,
let lastTimestamp = sortedTimestamps.last,
lastTimestamp > firstTimestamp
else {
throw PPGSignalError.invalidTimestamps
}
let duration = lastTimestamp - firstTimestamp
guard duration >= configuration.minimumAnalysisDurationSeconds else {
throw PPGSignalError.insufficientDuration(
requiredSeconds: configuration.minimumAnalysisDurationSeconds,
actualSeconds: duration
)
}
let red = try resample(finiteSamples, value: \.red)
let green = try resample(finiteSamples, value: \.green)
let blue = try resample(finiteSamples, value: \.blue)
let luminance = try resample(finiteSamples, value: \.luminance)
let contact = try resample(finiteSamples, value: \.contactConfidence)
let saturation = try resample(finiteSamples, value: \.saturationRatio)
let motion = try resample(finiteSamples, value: \.motionArtifactScore)
let greenRedRatio = try PPGSignalResampler.resample(
finiteSamples.map {
let total = max($0.red + $0.green + $0.blue, 1)
return TimestampedValue(
timestampSeconds: $0.timestampSeconds,
value: $0.green / total
)
},
targetSampleRateHz: configuration.targetSampleRateHz,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds
)
let blueRedRatio = try PPGSignalResampler.resample(
finiteSamples.map {
let total = max($0.red + $0.green + $0.blue, 1)
return TimestampedValue(
timestampSeconds: $0.timestampSeconds,
value: $0.blue / total
)
},
targetSampleRateHz: configuration.targetSampleRateHz,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds
)
guard red.values.count == green.values.count,
red.values.count == luminance.values.count,
red.values.count == contact.values.count,
red.values.count == saturation.values.count,
red.values.count == motion.values.count
else {
throw PPGSignalError.invalidTimestamps
}
let blendValues = normalizedBlend(
red: red.values,
green: green.values,
blue: blue.values,
luminance: luminance.values
)
let blendMask = zip(red.validityMask, zip(green.validityMask, luminance.validityMask)).map {
$0.0 && $0.1.0 && $0.1.1
}
let blend = ResampledSignal(
timestampsSeconds: red.timestampsSeconds,
values: blendValues,
validityMask: blendMask,
diagnostics: red.diagnostics
)
let filter = try PPGBandPassFilter(
sampleRateHz: configuration.targetSampleRateHz,
lowCutoffHz: configuration.minimumFrequencyHz,
highCutoffHz: configuration.maximumFrequencyHz,
tapCount: configuration.filterTapCount
)
let commonMetricMask = zip(contact.validityMask, zip(saturation.validityMask, motion.validityMask))
.map { $0.0 && $0.1.0 && $0.1.1 }
// The coaching contact score must never decide whether a frame contains a
// pulse. Camera colour varies too much between front sensors and exposure
// modes. Only timestamp continuity and extreme optical corruption create
// invalid regions; periodicity and beat consistency decide quality later.
let physiologicalMask = contact.values.indices.map { index in
commonMetricMask[index]
&& saturation.values[index] <= configuration.maximumSaturationRatio
&& motion.values[index] <= configuration.maximumMotionArtifactScore
}
let guardedPhysiologicalMask = PPGSignalPreprocessor.dilateInvalidRegions(
physiologicalMask,
radius: max(Int((configuration.targetSampleRateHz * 0.20).rounded()), 1)
)
let sourceCandidates: [(PPGSignalChannel, ResampledSignal)] = [
(.red, red),
(.green, green),
(.blue, blue),
(.luminance, luminance),
(.greenRedRatio, greenRedRatio),
(.blueRedRatio, blueRedRatio),
(.normalizedBlend, blend),
]
let candidates = try sourceCandidates.map { channel, resampled in
try analyzeChannel(
channel: channel,
resampled: resampled,
guardedPhysiologicalMask: guardedPhysiologicalMask,
filter: filter
)
}
guard var selected = candidates.max(by: channelCandidateIsWorse) else {
throw PPGSignalError.noUsableChannel
}
selected.timebase.inputSampleCount = inputSamples.count
selected.timebase.discardedSampleCount += discardedInputCount
let prvCalculation = try PPGPRVCalculator.calculate(
intervals: selected.peakDetection.intervals,
configuration: configuration
)
let validCoverage = Double(selected.validityMask.filter { $0 }.count)
/ Double(max(selected.validityMask.count, 1))
var qualityTimebase = selected.timebase
qualityTimebase.usableDurationSeconds = min(
qualityTimebase.usableDurationSeconds,
qualityTimebase.durationSeconds * validCoverage
)
let quality = PPGSignalQualityEvaluator.evaluate(
SignalQualityInput(
timebase: qualityTimebase,
contactConfidence: contact.values,
saturationRatio: saturation.values,
motionArtifactScore: motion.values,
filteredSignal: selected.filteredSignal,
validityMask: selected.validityMask,
bpmEstimate: selected.bpm,
intervals: prvCalculation.intervals,
signalToNoiseRatioDB: selected.snrDB,
channelStabilityScore: selected.selectionScore
),
configuration: configuration
)
// A camera PPG can provide a useful mean heart rate before it provides
// beat-to-beat timing good enough for PRV. Publish that conservative BPM
// when the evaluator did not mark the signal structurally invalid; keep
// PRV reliability gated below by the stricter quality/confidence checks.
// This mirrors how practical camera HR apps remain useful on borderline
// captures without turning them into HRV measurements.
let isValid = quality.level != .invalid
&& quality.score >= PPGPublicationThresholds.bpmQualityScore
&& selected.bpm.confidence >= PPGPublicationThresholds.bpmConfidence
&& selected.bpm.bpm != nil
var publishedBPM = selected.bpm
var publishedPRV = prvCalculation.metrics
if !isValid {
// Keep independent estimates for diagnostics, but do not publish a definitive BPM.
publishedBPM.bpm = nil
publishedPRV.reliable = false
} else if quality.score < PPGPublicationThresholds.prvQualityScore
|| selected.bpm.confidence < PPGPublicationThresholds.prvBpmConfidence {
// Beat-to-beat variability requires a stricter signal than mean pulse rate.
publishedPRV.reliable = false
}
return PPGAnalysisResult(
status: isValid ? .valid : .invalid,
bpm: publishedBPM,
prv: publishedPRV,
quality: quality,
peaks: selected.peakDetection.peaks,
intervals: prvCalculation.intervals,
timebase: selected.timebase
)
}
private struct ChannelCandidate {
var channel: PPGSignalChannel
var filteredSignal: [Double]
var validityMask: [Bool]
var timebase: TimebaseDiagnostics
var peakDetection: PeakDetectionResult
var bpm: BPMEstimate
var snrDB: Double?
var selectionScore: Double
}
private func analyzeChannel(
channel: PPGSignalChannel,
resampled: ResampledSignal,
guardedPhysiologicalMask: [Bool],
filter: PPGBandPassFilter
) throws -> ChannelCandidate {
let prepared = PPGSignalPreprocessor.detrendAndNormalize(
resampled.values,
sampleRateHz: configuration.targetSampleRateHz
)
let filtered = filter.apply(to: prepared)
let gapGuardedMask = PPGSignalPreprocessor.dilateInvalidRegions(
resampled.validityMask,
radius: filter.invalidRegionGuardRadius
)
var validityMask = zip(gapGuardedMask, guardedPhysiologicalMask).map { $0.0 && $0.1 }
let edgeGuard = min(filter.invalidRegionGuardRadius, validityMask.count / 4)
if edgeGuard > 0 {
for index in 0..<edgeGuard { validityMask[index] = false }
for index in (validityMask.count - edgeGuard)..<validityMask.count {
validityMask[index] = false
}
}
let peakDetection = try PPGPeakDetector.detect(
signal: filtered,
timestampsSeconds: resampled.timestampsSeconds,
validityMask: validityMask,
configuration: configuration
)
let bpm = try PPGBPMEstimator.estimate(
signal: filtered,
timestampsSeconds: resampled.timestampsSeconds,
validityMask: validityMask,
peakDetection: peakDetection,
selectedChannel: channel,
configuration: configuration
)
let snrDB = bpm.frequencyBpm.flatMap { frequencyBPM in
SignalStatistics.periodicBandSNRDB(
values: filtered,
timestamps: resampled.timestampsSeconds,
validityMask: validityMask,
fundamentalFrequencyHz: frequencyBPM / 60,
minimumFrequencyHz: configuration.minimumFrequencyHz,
maximumFrequencyHz: configuration.maximumFrequencyHz
)
}
let snrScore = snrDB.map { SignalStatistics.clamp01(($0 + 3) / 15) } ?? 0
let coverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1))
let acceptedIntervals = peakDetection.acceptedIntervals
let intervalAcceptance = peakDetection.intervals.isEmpty
? 0
: Double(acceptedIntervals.count) / Double(peakDetection.intervals.count)
let beatQuality = acceptedIntervals.isEmpty
? 0
: SignalStatistics.mean(acceptedIntervals.map(\.qualityScore))
let selectionScore = SignalStatistics.clamp01(
0.42 * bpm.confidence
+ 0.23 * snrScore
+ 0.13 * coverage
+ 0.12 * intervalAcceptance
+ 0.10 * beatQuality
)
return ChannelCandidate(
channel: channel,
filteredSignal: filtered,
validityMask: validityMask,
timebase: resampled.diagnostics,
peakDetection: peakDetection,
bpm: bpm,
snrDB: snrDB,
selectionScore: selectionScore
)
}
private func resample(
_ samples: [PPGFrameSample],
value keyPath: KeyPath<PPGFrameSample, Double>
) throws -> ResampledSignal {
try PPGSignalResampler.resample(
samples.map {
TimestampedValue(timestampSeconds: $0.timestampSeconds, value: $0[keyPath: keyPath])
},
targetSampleRateHz: configuration.targetSampleRateHz,
maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds
)
}
private func normalizedBlend(
red: [Double],
green: [Double],
blue: [Double],
luminance: [Double]
) -> [Double] {
let standardizedRed = robustStandardize(red)
let standardizedGreen = robustStandardize(green)
let standardizedBlue = robustStandardize(blue)
let standardizedLuminance = robustStandardize(luminance)
return standardizedRed.indices.map { index in
// A compact chrominance projection suppresses common illumination and
// automatic-exposure changes while preserving blood-volume modulation.
0.70 * standardizedGreen[index]
- 0.40 * standardizedRed[index]
- 0.30 * standardizedBlue[index]
+ 0.10 * standardizedLuminance[index]
}
}
private func robustStandardize(_ values: [Double]) -> [Double] {
let center = SignalStatistics.median(values)
let scale = max(
1.4826 * SignalStatistics.mad(values, around: center),
SignalStatistics.standardDeviation(values) * 0.20,
1e-9
)
return values.map { min(max(($0 - center) / scale, -12), 12) }
}
private func channelCandidateIsWorse(_ lhs: ChannelCandidate, _ rhs: ChannelCandidate) -> Bool {
if abs(lhs.selectionScore - rhs.selectionScore) > 1e-12 {
return lhs.selectionScore < rhs.selectionScore
}
let lhsIndex = PPGSignalChannel.allCases.firstIndex(of: lhs.channel) ?? 0
let rhsIndex = PPGSignalChannel.allCases.firstIndex(of: rhs.channel) ?? 0
// Prefer the earlier, simpler channel when scores are exactly tied.
return lhsIndex > rhsIndex
}
}
@@ -0,0 +1,176 @@
import Foundation
public struct SignalQualityInput: Equatable, Sendable {
public var timebase: TimebaseDiagnostics
public var contactConfidence: [Double]
public var saturationRatio: [Double]
public var motionArtifactScore: [Double]
public var filteredSignal: [Double]
public var validityMask: [Bool]
public var bpmEstimate: BPMEstimate
public var intervals: [PulseInterval]
public var signalToNoiseRatioDB: Double?
public var channelStabilityScore: Double
public init(
timebase: TimebaseDiagnostics,
contactConfidence: [Double],
saturationRatio: [Double],
motionArtifactScore: [Double],
filteredSignal: [Double],
validityMask: [Bool],
bpmEstimate: BPMEstimate,
intervals: [PulseInterval],
signalToNoiseRatioDB: Double?,
channelStabilityScore: Double
) {
self.timebase = timebase
self.contactConfidence = contactConfidence
self.saturationRatio = saturationRatio
self.motionArtifactScore = motionArtifactScore
self.filteredSignal = filteredSignal
self.validityMask = validityMask
self.bpmEstimate = bpmEstimate
self.intervals = intervals
self.signalToNoiseRatioDB = signalToNoiseRatioDB
self.channelStabilityScore = SignalStatistics.clamp01(channelStabilityScore)
}
}
public enum PPGSignalQualityEvaluator {
public static func evaluate(
_ input: SignalQualityInput,
configuration: PPGProcessingConfiguration
) -> SignalQualityResult {
let totalCount = max(input.validityMask.count, 1)
let validCoverage = Double(input.validityMask.filter { $0 }.count) / Double(totalCount)
let contactMean = boundedMean(input.contactConfidence)
let saturationMean = boundedMean(input.saturationRatio)
let motionMean = boundedMean(input.motionArtifactScore)
let motionFrameRatio = input.motionArtifactScore.isEmpty
? 0
: Double(input.motionArtifactScore.filter { $0 >= 0.50 }.count)
/ Double(input.motionArtifactScore.count)
let motionArtifactRatio = max(motionMean, motionFrameRatio)
let validSignal = input.filteredSignal.indices.compactMap { index -> Double? in
guard index < input.validityMask.count,
input.validityMask[index],
input.filteredSignal[index].isFinite
else { return nil }
return input.filteredSignal[index]
}
let pulsatileAmplitude = validSignal.isEmpty
? 0
: SignalStatistics.percentile(validSignal, 0.95)
- SignalStatistics.percentile(validSignal, 0.05)
let amplitudeScore = SignalStatistics.clamp01(pulsatileAmplitude / 2.0)
let snrScore = input.signalToNoiseRatioDB.map {
SignalStatistics.clamp01(($0 + 3) / 15)
} ?? 0
let sampleInterval = input.timebase.estimatedSampleRateHz > 0
? 1 / input.timebase.estimatedSampleRateHz
: 0
let jitterRatio = sampleInterval > 0
? input.timebase.frameIntervalMADSeconds / sampleInterval
: 1
let droppedFrameScore = SignalStatistics.clamp01(1 - input.timebase.droppedFrameRatio / 0.15)
let jitterScore = SignalStatistics.clamp01(1 - jitterRatio / 0.15)
let gapScore = input.timebase.maximumGapSeconds <= configuration.maximumInterpolationGapSeconds
? 1.0
: SignalStatistics.clamp01(
1 - input.timebase.longGapDurationSeconds / max(input.timebase.durationSeconds, 1e-9)
)
let timebaseScore = 0.45 * droppedFrameScore + 0.35 * jitterScore + 0.20 * gapScore
let durationScore = SignalStatistics.clamp01(
input.timebase.usableDurationSeconds / configuration.minimumAnalysisDurationSeconds
)
let contactScore = SignalStatistics.clamp01(contactMean)
let saturationScore = SignalStatistics.clamp01(1 - saturationMean / 0.70)
let motionScore = SignalStatistics.clamp01(1 - motionArtifactRatio / 0.75)
let peakScore = input.bpmEstimate.confidence
let agreementScore = input.bpmEstimate.methodAgreementBpm.map {
SignalStatistics.clamp01(1 - $0 / max(configuration.maximumMethodDisagreementBPM, 1e-9))
} ?? 0
let rejectedIntervalRatio = input.intervals.isEmpty
? 1
: Double(input.intervals.filter { !$0.isAccepted }.count) / Double(input.intervals.count)
let intervalScore = SignalStatistics.clamp01(1 - rejectedIntervalRatio / 0.35)
let score = 0.03 * contactScore
+ 0.05 * saturationScore
+ 0.06 * motionScore
+ 0.14 * timebaseScore
+ 0.12 * durationScore
+ 0.08 * amplitudeScore
+ 0.20 * snrScore
+ 0.14 * peakScore
+ 0.09 * agreementScore
+ 0.05 * intervalScore
+ 0.04 * input.channelStabilityScore
var issues: [SignalQualityIssue] = []
if input.timebase.usableDurationSeconds < configuration.minimumAnalysisDurationSeconds {
issues.append(.insufficientDuration)
}
if input.timebase.outOfOrderTimestampCount > 0 || input.timebase.duplicateTimestampCount > 0 {
issues.append(.invalidTimestamps)
}
if input.timebase.maximumGapSeconds > configuration.longGapLimitSeconds {
issues.append(.longInterruption)
}
if input.timebase.droppedFrameRatio > 0.12 { issues.append(.tooManyDroppedFrames) }
if jitterRatio > 0.12 { issues.append(.irregularTimestamps) }
if contactMean < 0.35 || validCoverage < 0.75 { issues.append(.poorFingerContact) }
if saturationMean > 0.60 { issues.append(.excessiveSaturation) }
if motionArtifactRatio > 0.55 { issues.append(.motionArtifacts) }
if pulsatileAmplitude < 0.75 { issues.append(.lowPulsatileAmplitude) }
if (input.signalToNoiseRatioDB ?? -.infinity) < 3 { issues.append(.lowSignalToNoise) }
if input.bpmEstimate.validPeakCount < 8 { issues.append(.tooFewPeaks) }
if input.bpmEstimate.bpm == nil { issues.append(.inconsistentBPMMethods) }
if rejectedIntervalRatio > 0.25 { issues.append(.tooManyRejectedIntervals) }
if input.channelStabilityScore < 0.45 { issues.append(.unstableChannel) }
if input.timebase.discardedSampleCount > 0 { issues.append(.nonFiniteInput) }
let hardInvalid = input.bpmEstimate.bpm == nil
|| input.signalToNoiseRatioDB == nil
|| (input.signalToNoiseRatioDB ?? -.infinity) < 0
|| input.bpmEstimate.confidence < PPGPublicationThresholds.bpmConfidence
|| input.timebase.maximumGapSeconds > configuration.longGapLimitSeconds
|| validCoverage < 0.55
|| saturationMean > 0.92
|| motionArtifactRatio > 0.90
|| input.timebase.usableDurationSeconds < configuration.minimumAnalysisDurationSeconds * 0.80
let clampedScore = SignalStatistics.clamp01(score)
let level: SignalQualityLevel
if hardInvalid || clampedScore < PPGPublicationThresholds.bpmQualityScore {
level = .invalid
} else if clampedScore < configuration.minimumQualityScore {
level = .poor
} else if clampedScore < PPGPublicationThresholds.prvQualityScore {
level = .medium
} else {
level = .good
}
return SignalQualityResult(
score: clampedScore,
level: level,
usableDurationSeconds: input.timebase.usableDurationSeconds,
droppedFrameRatio: input.timebase.droppedFrameRatio,
saturationRatio: saturationMean,
rejectedIntervalRatio: rejectedIntervalRatio,
signalToNoiseRatio: input.signalToNoiseRatioDB,
motionArtifactRatio: motionArtifactRatio,
methodAgreementScore: agreementScore,
issues: issues
)
}
private static func boundedMean(_ values: [Double]) -> Double {
let finite = values.filter(\.isFinite).map { min(max($0, 0), 1) }
return finite.isEmpty ? 0 : SignalStatistics.mean(finite)
}
}
@@ -0,0 +1,196 @@
import Foundation
enum SignalStatistics {
static func mean(_ values: [Double]) -> Double {
guard !values.isEmpty else { return 0 }
return values.reduce(0, +) / Double(values.count)
}
static func median(_ values: [Double]) -> Double {
guard !values.isEmpty else { return 0 }
let sorted = values.sorted()
let middle = sorted.count / 2
if sorted.count.isMultiple(of: 2) {
return (sorted[middle - 1] + sorted[middle]) / 2
}
return sorted[middle]
}
static func mad(_ values: [Double], around center: Double? = nil) -> Double {
guard !values.isEmpty else { return 0 }
let center = center ?? median(values)
return median(values.map { abs($0 - center) })
}
static func standardDeviation(_ values: [Double], sample: Bool = true) -> Double {
guard values.count > (sample ? 1 : 0) else { return 0 }
let average = mean(values)
let sumSquares = values.reduce(0) { partial, value in
let delta = value - average
return partial + delta * delta
}
let divisor = Double(values.count - (sample ? 1 : 0))
return (sumSquares / divisor).squareRoot()
}
static func rootMeanSquare(_ values: [Double]) -> Double {
guard !values.isEmpty else { return 0 }
let squareMean = values.reduce(0) { $0 + $1 * $1 } / Double(values.count)
return squareMean.squareRoot()
}
static func percentile(_ values: [Double], _ probability: Double) -> Double {
guard !values.isEmpty else { return 0 }
let sorted = values.sorted()
let p = min(max(probability, 0), 1)
let position = p * Double(sorted.count - 1)
let lower = Int(position.rounded(.down))
let upper = Int(position.rounded(.up))
guard lower != upper else { return sorted[lower] }
let fraction = position - Double(lower)
return sorted[lower] + fraction * (sorted[upper] - sorted[lower])
}
static func clamp01(_ value: Double) -> Double {
guard value.isFinite else { return 0 }
return min(max(value, 0), 1)
}
static func reflectedIndex(_ index: Int, count: Int) -> Int {
guard count > 1 else { return 0 }
var candidate = index
while candidate < 0 || candidate >= count {
if candidate < 0 {
candidate = -candidate - 1
}
if candidate >= count {
candidate = 2 * count - candidate - 1
}
}
return candidate
}
/// Fits a sinusoid at `frequencyHz` and reports its power relative to residual power in dB.
static func sinusoidalSNRDB(
values: [Double],
timestamps: [Double],
validityMask: [Bool],
frequencyHz: Double
) -> Double? {
guard values.count == timestamps.count,
values.count == validityMask.count,
frequencyHz.isFinite,
frequencyHz > 0
else { return nil }
let validIndices = values.indices.filter {
validityMask[$0] && values[$0].isFinite && timestamps[$0].isFinite
}
guard validIndices.count >= 12 else { return nil }
let average = validIndices.reduce(0) { $0 + values[$1] } / Double(validIndices.count)
var sinSin = 0.0
var cosCos = 0.0
var sinCos = 0.0
var valueSin = 0.0
var valueCos = 0.0
let origin = timestamps[validIndices[0]]
for index in validIndices {
let angle = 2 * Double.pi * frequencyHz * (timestamps[index] - origin)
let sine = sin(angle)
let cosine = cos(angle)
let value = values[index] - average
sinSin += sine * sine
cosCos += cosine * cosine
sinCos += sine * cosine
valueSin += value * sine
valueCos += value * cosine
}
let determinant = sinSin * cosCos - sinCos * sinCos
guard abs(determinant) > 1e-12 else { return nil }
let sineCoefficient = (valueSin * cosCos - valueCos * sinCos) / determinant
let cosineCoefficient = (valueCos * sinSin - valueSin * sinCos) / determinant
var modeledPower = 0.0
var residualPower = 0.0
for index in validIndices {
let angle = 2 * Double.pi * frequencyHz * (timestamps[index] - origin)
let modeled = sineCoefficient * sin(angle) + cosineCoefficient * cos(angle)
let value = values[index] - average
modeledPower += modeled * modeled
let residual = value - modeled
residualPower += residual * residual
}
guard modeledPower > 1e-12 else { return -60 }
let ratio = modeledPower / max(residualPower, 1e-12)
return 10 * log10(ratio)
}
/// Compares average spectral power around the pulse fundamental (and its second harmonic)
/// with the remaining physiological band. The finite bandwidth tolerates slow BPM changes.
static func periodicBandSNRDB(
values: [Double],
timestamps: [Double],
validityMask: [Bool],
fundamentalFrequencyHz: Double,
minimumFrequencyHz: Double,
maximumFrequencyHz: Double
) -> Double? {
guard values.count == timestamps.count,
values.count == validityMask.count,
values.count >= 30,
fundamentalFrequencyHz.isFinite,
fundamentalFrequencyHz > 0,
minimumFrequencyHz > 0,
maximumFrequencyHz > minimumFrequencyHz
else { return nil }
let validIndices = values.indices.filter {
validityMask[$0] && values[$0].isFinite && timestamps[$0].isFinite
}
guard validIndices.count >= 30 else { return nil }
let origin = timestamps[validIndices[0]]
let duration = timestamps[validIndices.last!] - origin
guard duration > 2 else { return nil }
let average = validIndices.reduce(0) { $0 + values[$1] } / Double(validIndices.count)
let frequencyStep = max(1 / duration, 0.02)
let fundamentalHalfWidth = max(0.12, min(0.28, fundamentalFrequencyHz * 0.18))
let secondHarmonic = 2 * fundamentalFrequencyHz
let harmonicHalfWidth = min(0.36, 1.5 * fundamentalHalfWidth)
var signalPowers: [Double] = []
var noisePowers: [Double] = []
var frequency = minimumFrequencyHz
while frequency <= maximumFrequencyHz + frequencyStep * 0.5 {
var real = 0.0
var imaginary = 0.0
for index in validIndices {
let relativeTime = timestamps[index] - origin
let window = 0.5 - 0.5 * cos(2 * Double.pi * relativeTime / duration)
let centeredValue = (values[index] - average) * window
let angle = -2 * Double.pi * frequency * relativeTime
real += centeredValue * cos(angle)
imaginary += centeredValue * sin(angle)
}
let power = real * real + imaginary * imaginary
let nearFundamental = abs(frequency - fundamentalFrequencyHz) <= fundamentalHalfWidth
let nearSecondHarmonic = secondHarmonic <= maximumFrequencyHz
&& abs(frequency - secondHarmonic) <= harmonicHalfWidth
if nearFundamental || nearSecondHarmonic {
signalPowers.append(power)
} else {
noisePowers.append(power)
}
frequency += frequencyStep
}
guard !signalPowers.isEmpty, !noisePowers.isEmpty else { return nil }
let signalPower = mean(signalPowers)
let noisePower = mean(noisePowers)
guard signalPower > 1e-12 else { return -60 }
return 10 * log10(signalPower / max(noisePower, 1e-12))
}
}
@@ -0,0 +1,65 @@
import Foundation
import React
/// Thin React Native facade over the central WCSession owner. The PPG and
/// Core Haptics objects remain Nitro modules; connectivity is intentionally a
/// process-wide service and therefore must not be instantiated per HybridObject.
@objc(HaptikaWatchConnectivity)
final class HaptikaWatchConnectivityModule: NSObject {
@objc static func requiresMainQueueSetup() -> Bool { false }
@objc func start() {
HaptikaWatchConnectivityService.shared.activate()
}
@objc func getConnectionState(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
let state = HaptikaWatchConnectivityService.shared.state()
resolve([
"supported": state.supported,
"paired": state.paired,
"watchAppInstalled": state.installed,
"reachable": state.reachable,
"activationState": state.activationState,
"latestEventId": state.latestEventId ?? NSNull(),
"latestEventAt": state.latestEventAt ?? NSNull()
])
}
@objc func drainEvents(
_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.drainEvents())
}
@objc func acknowledgeEvents(_ eventIds: [String]) {
HaptikaWatchConnectivityService.shared.acknowledge(eventIds: eventIds)
}
@objc func sendPreferences(
_ json: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.sendPreferences(json: json))
}
@objc func sendCommand(
_ json: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.sendCommand(json: json))
}
@objc func transferEvent(
_ json: String,
resolver resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock
) {
resolve(HaptikaWatchConnectivityService.shared.transferEvent(json: json))
}
}
@@ -0,0 +1,21 @@
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(HaptikaWatchConnectivity, NSObject)
RCT_EXTERN_METHOD(start)
RCT_EXTERN_METHOD(getConnectionState:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(drainEvents:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(acknowledgeEvents:(NSArray<NSString *> *)eventIds)
RCT_EXTERN_METHOD(sendPreferences:(NSString *)json
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(sendCommand:(NSString *)json
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(transferEvent:(NSString *)json
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end
@@ -0,0 +1,490 @@
import Foundation
import WatchConnectivity
/// The only owner of `WCSession` in the iPhone process.
///
/// Durable Watch envelopes are persisted before being exposed to JavaScript.
/// JavaScript explicitly acknowledges imported event IDs, after which the
/// service removes them and sends an acknowledgement to the paired Watch.
final class HaptikaWatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = HaptikaWatchConnectivityService()
struct ConnectionState {
let supported: Bool
let paired: Bool
let installed: Bool
let reachable: Bool
let activationState: String
let latestEventId: String?
let latestEventAt: String?
}
struct LiveHeartRate {
let bpm: Double
let timestampSeconds: Double
}
private static let inboxKey = "HaptikaWatchConnectivity.inbox.v1"
private let lock = NSLock()
private let defaults = UserDefaults.standard
private var memoryLiveEvents: [String] = []
private var latestEventIdValue: String?
private var latestEventAtValue: String?
private var latestHeartRateValue: LiveHeartRate?
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
if session.activationState != .activated {
session.activate()
}
}
func state() -> ConnectionState {
guard WCSession.isSupported() else {
return ConnectionState(supported: false, paired: false, installed: false, reachable: false, activationState: "notActivated", latestEventId: nil, latestEventAt: nil)
}
let session = WCSession.default
lock.lock()
let eventId = latestEventIdValue
let eventAt = latestEventAtValue
lock.unlock()
let activation: String
switch session.activationState {
case .activated: activation = "activated"
case .inactive: activation = "inactive"
case .notActivated: activation = "notActivated"
@unknown default: activation = "notActivated"
}
return ConnectionState(
supported: true,
paired: session.isPaired,
installed: session.isWatchAppInstalled,
reachable: session.isReachable,
activationState: activation,
latestEventId: eventId,
latestEventAt: eventAt
)
}
func latestHeartRate() -> LiveHeartRate? {
lock.lock()
defer { lock.unlock() }
return latestHeartRateValue
}
func drainEvents() -> [String] {
lock.lock()
defer { lock.unlock() }
let durable = defaults.stringArray(forKey: Self.inboxKey) ?? []
return durable + memoryLiveEvents
}
func acknowledge(eventIds: [String]) {
guard !eventIds.isEmpty else { return }
let identifiers = Set(eventIds)
lock.lock()
let durable = defaults.stringArray(forKey: Self.inboxKey) ?? []
defaults.set(durable.filter { json in
guard let eventId = Self.eventId(in: json) else { return false }
return !identifiers.contains(eventId)
}, forKey: Self.inboxKey)
memoryLiveEvents.removeAll { json in
guard let eventId = Self.eventId(in: json) else { return true }
return identifiers.contains(eventId)
}
lock.unlock()
sendAcknowledgement(eventIds: eventIds)
}
func sendPreferences(json: String) -> Bool {
guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false }
let rawPayload = (dictionary["payload"] as? [String: Any]) ?? dictionary
let save = (rawPayload["saveMindAndBodyWorkouts"] as? Bool) ?? true
let catalogVersion = (rawPayload["protocolCatalogVersion"] as? String) ?? "1.0.0"
let preferences: [String: Any] = [
"schemaVersion": 1,
"saveMindAndBodyWorkouts": save,
"protocolCatalogVersion": catalogVersion,
"updatedAt": (rawPayload["updatedAt"] as? String) ?? Self.iso8601.string(from: Date())
]
guard let data = try? JSONSerialization.data(withJSONObject: preferences, options: [.sortedKeys]) else { return false }
activate()
do {
try WCSession.default.updateApplicationContext(["preferences": data])
return true
} catch {
return false
}
}
func sendCommand(json: String) -> Bool {
guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false }
activate()
guard WCSession.default.isReachable else { return false }
let payload = (dictionary["payload"] as? [String: Any]) ?? dictionary
guard let envelope = Self.watchWireEnvelope(kind: "mirrorCommand", payload: payload),
let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false }
WCSession.default.sendMessageData(encoded, replyHandler: nil, errorHandler: nil)
return true
}
func transferEvent(json: String) -> Bool {
guard let dictionary = Self.dictionary(from: json), WCSession.isSupported(),
let kind = dictionary["kind"] as? String,
let payloadObject = dictionary["payload"],
JSONSerialization.isValidJSONObject(payloadObject),
let envelope = Self.watchWireEnvelope(kind: kind, payload: payloadObject),
let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false }
activate()
WCSession.default.transferUserInfo(["envelope": encoded])
return true
}
private func receive(_ dictionary: [String: Any], durable: Bool) {
guard let normalized = normalize(dictionary),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.sortedKeys]),
let json = String(data: data, encoding: .utf8) else { return }
let kind = normalized["kind"] as? String
let eventId = normalized["eventId"] as? String
let sentAt = normalized["sentAt"] as? String
lock.lock()
latestEventIdValue = eventId
latestEventAtValue = sentAt
if kind == "liveHeartRate", let payload = normalized["payload"] as? [String: Any],
let bpm = Self.double(payload["bpm"]), bpm >= 35, bpm <= 220,
let timestamp = Self.double(payload["timestampSeconds"]), timestamp.isFinite {
latestHeartRateValue = LiveHeartRate(bpm: bpm, timestampSeconds: timestamp)
}
if durable {
var inbox = defaults.stringArray(forKey: Self.inboxKey) ?? []
if eventId == nil || !inbox.contains(where: { Self.eventId(in: $0) == eventId }) {
inbox.append(json)
defaults.set(Array(inbox.suffix(500)), forKey: Self.inboxKey)
}
} else {
memoryLiveEvents.append(json)
if memoryLiveEvents.count > 60 { memoryLiveEvents.removeFirst(memoryLiveEvents.count - 60) }
}
lock.unlock()
}
private func normalize(_ dictionary: [String: Any]) -> [String: Any]? {
if dictionary["kind"] != nil,
dictionary["eventId"] != nil || dictionary["eventID"] != nil {
return normalizeEnvelopeDictionary(dictionary)
}
// Compatibility with the early prototype. It remains a live HR sample and
// is never treated as a biological heartbeat.
guard let bpm = Self.double(dictionary["bpm"]), bpm >= 35, bpm <= 220,
let timestamp = Self.double(dictionary["timestampSeconds"]) else { return nil }
return [
"schemaVersion": 1,
"eventId": "legacy-live-\(UUID().uuidString)",
"deviceId": "legacy-watch",
"sequence": 0,
"sentAt": Self.iso8601.string(from: Date()),
"kind": "liveHeartRate",
"payload": ["bpm": bpm, "timestampSeconds": timestamp]
]
}
private func normalizeEnvelopeDictionary(_ dictionary: [String: Any]) -> [String: Any]? {
let eventId = (dictionary["eventId"] ?? dictionary["eventID"]) as? String
let deviceId = (dictionary["deviceId"] ?? dictionary["deviceID"]) as? String
guard let eventId, let deviceId, let kindValue = dictionary["kind"] as? String else { return nil }
let kind = kindValue == "acknowledgement" ? "ack" : kindValue
let sentAt = (dictionary["sentAt"] as? String) ?? Self.iso8601.string(from: Date())
let sequence = Self.double(dictionary["sequence"]) ?? 0
let payloadObject: Any
if let payload = dictionary["payload"] as? [String: Any] {
payloadObject = payload
} else if let base64 = dictionary["payload"] as? String,
let data = Data(base64Encoded: base64),
let decoded = try? JSONSerialization.jsonObject(with: data) {
payloadObject = decoded
} else if let data = dictionary["payload"] as? Data,
let decoded = try? JSONSerialization.jsonObject(with: data) {
payloadObject = decoded
} else {
payloadObject = NSNull()
}
let normalizedPayload = normalizePayload(payloadObject, kind: kind, deviceId: deviceId, sentAt: sentAt)
return [
"schemaVersion": 1,
"eventId": eventId,
"deviceId": deviceId,
"sequence": sequence,
"sentAt": sentAt,
"kind": kind,
"payload": normalizedPayload
]
}
private func normalizePayload(_ payload: Any, kind: String, deviceId: String, sentAt: String) -> Any {
guard let value = payload as? [String: Any] else { return payload }
// Current watchOS builds encode the exact cross-platform V1 DTO. Do not
// reinterpret canonical fields such as `coverage`, `currentBpm` or the
// compact sample offsets.
if Self.double(value["schemaVersion"]) == 1 {
if kind == "measurementUpsert" {
var canonical = value
for key in ["currentBpm", "averageBpm", "minimumBpm", "maximumBpm", "invalidReason", "feeling"] where canonical[key] == nil {
canonical[key] = NSNull()
}
if var device = canonical["device"] as? [String: Any] {
if device["model"] == nil { device["model"] = NSNull() }
if device["systemVersion"] == nil { device["systemVersion"] = NSNull() }
canonical["device"] = device
}
if var context = canonical["context"] as? [String: Any] {
if context["posture"] == nil { context["posture"] = NSNull() }
for key in ["recentExercise", "recentCaffeine", "recentMeal"] where context[key] == nil {
context[key] = NSNull()
}
canonical["context"] = context
}
return canonical
}
if kind == "regulationSessionUpsert" {
var canonical = value
for key in ["beforeMeasurementId", "afterMeasurementId", "endedAt", "workoutUUID", "feelingBefore", "feelingAfter"] where canonical[key] == nil {
canonical[key] = NSNull()
}
return canonical
}
if ["tombstone", "preferences"].contains(kind) { return value }
}
switch kind {
case "measurementUpsert":
guard let id = value["id"] as? String,
let startedAt = value["startedAt"] as? String,
let endedAt = value["endedAt"] as? String else { return value }
let updatedAt = (value["updatedAt"] as? String) ?? sentAt
let startedSeconds = Self.timestamp(startedAt) ?? 0
let duration = max(1, (Self.timestamp(endedAt) ?? startedSeconds + 60) - startedSeconds)
let coverageSeconds = Self.double(value["coverageSeconds"]) ?? 0
let rawSamples = value["samples"] as? [[String: Any]] ?? []
let samples: [[String: Any]] = rawSamples.compactMap { sample in
guard let bpm = Self.double(sample["bpm"]),
let date = sample["date"] as? String,
let sampleTimestamp = Self.timestamp(date) else { return nil }
return ["offsetSeconds": max(0, sampleTimestamp - startedSeconds), "bpm": bpm]
}
let context = value["context"] as? [String: Any] ?? [:]
let postureValue = context["posture"] as? String
let posture: Any = ["seated", "lying", "standing"].contains(postureValue ?? "")
? Self.jsonString(postureValue)
: NSNull()
let reasons = value["invalidReasons"] as? [String] ?? []
let feeling: Any
if let score = Self.double(value["feeling"]), (1 ... 5).contains(score) {
feeling = ["score": Int(score), "recordedAt": updatedAt]
} else {
feeling = NSNull()
}
let devicePayload: [String: Any] = [
"id": deviceId,
"name": (value["deviceName"] as? String) ?? "Apple Watch",
"model": Self.jsonString(value["deviceModel"]),
"systemVersion": NSNull()
]
let contextPayload: [String: Any] = [
"atRest": (context["isAtRest"] as? Bool) ?? true,
"posture": posture,
"recentExercise": (context["recentExercise"] as? Bool) ?? false,
"recentCaffeine": (context["recentCaffeine"] as? Bool) ?? false,
"recentMeal": (context["recentMeal"] as? Bool) ?? false
]
let normalizedCoverage = min(1.0, max(0.0, coverageSeconds / duration))
let normalizedQuality = min(1.0, max(0.0, Self.double(value["quality"]) ?? 0.0))
let invalidReason: Any = reasons.isEmpty ? NSNull() : reasons.joined(separator: ",")
var normalized: [String: Any] = [:]
normalized["schemaVersion"] = 1
normalized["id"] = id
normalized["updatedAt"] = updatedAt
normalized["startedAt"] = startedAt
normalized["endedAt"] = endedAt
normalized["source"] = "appleWatchOptical"
normalized["device"] = devicePayload
normalized["currentBpm"] = Self.jsonNumber(value["currentBPM"])
normalized["averageBpm"] = Self.jsonNumber(value["averageBPM"])
normalized["minimumBpm"] = Self.jsonNumber(value["minimumBPM"])
normalized["maximumBpm"] = Self.jsonNumber(value["maximumBPM"])
normalized["samples"] = samples
normalized["coverage"] = normalizedCoverage
normalized["quality"] = normalizedQuality
normalized["valid"] = (value["isValid"] as? Bool) ?? false
normalized["invalidReason"] = invalidReason
normalized["context"] = contextPayload
normalized["feeling"] = feeling
return normalized
case "regulationSessionUpsert":
let sourceState = value["state"] as? String
let normalizedState = sourceState == "interrupted" ? "cancelled" : (sourceState ?? "cancelled")
var normalized: [String: Any] = [:]
normalized["schemaVersion"] = 1
normalized["id"] = value["id"] as? String ?? ""
normalized["updatedAt"] = value["updatedAt"] as? String ?? sentAt
normalized["protocolId"] = value["protocolID"] as? String ?? ""
normalized["beforeMeasurementId"] = value["beforeMeasurementID"] ?? NSNull()
normalized["afterMeasurementId"] = value["afterMeasurementID"] ?? NSNull()
normalized["startedAt"] = value["startedAt"] as? String ?? sentAt
normalized["endedAt"] = value["endedAt"] ?? NSNull()
normalized["durationSeconds"] = Self.double(value["durationSeconds"]) ?? 0.0
normalized["state"] = normalizedState
normalized["workoutUUID"] = value["healthKitWorkoutID"] ?? NSNull()
normalized["feelingBefore"] = value["feelingBefore"] ?? NSNull()
normalized["feelingAfter"] = value["feelingAfter"] ?? NSNull()
return normalized
case "tombstone":
let deletedAt = value["deletedAt"] as? String ?? sentAt
return [
"schemaVersion": 1,
"entityId": (value["entityId"] ?? value["entityID"]) as? String ?? "",
"entityKind": value["entityKind"] as? String ?? "measurement",
"deletedAt": deletedAt,
"updatedAt": deletedAt
]
case "liveHeartRate":
let sampledAt = value["sampledAt"] as? String
return [
"bpm": Self.double(value["bpm"]) ?? 0,
"timestampSeconds": sampledAt.flatMap(Self.timestamp) ?? Date().timeIntervalSince1970,
"measurementId": value["measurementID"] ?? NSNull()
]
default:
return value
}
}
private func sendAcknowledgement(eventIds: [String]) {
guard WCSession.isSupported() else { return }
activate()
let validEventIds = eventIds.compactMap(UUID.init(uuidString:)).map(\.uuidString)
guard !validEventIds.isEmpty else { return }
let payload: [String: Any] = ["eventIDs": validEventIds, "receivedAt": Self.iso8601.string(from: Date())]
guard let envelope = Self.watchWireEnvelope(kind: "ack", payload: payload),
let envelopeData = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return }
if WCSession.default.isReachable {
WCSession.default.sendMessageData(envelopeData, replyHandler: nil) { _ in
WCSession.default.transferUserInfo(["envelope": envelopeData])
}
} else {
WCSession.default.transferUserInfo(["envelope": envelopeData])
}
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) { session.activate() }
func sessionReachabilityDidChange(_ session: WCSession) {}
func sessionWatchStateDidChange(_ session: WCSession) {}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
receive(message, durable: (message["kind"] as? String) != "liveHeartRate")
}
func session(_ session: WCSession, didReceiveMessageData messageData: Data) {
receiveEnvelopeData(messageData, durable: false)
}
func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) {
receiveEnvelopeData(messageData, durable: false)
replyHandler(Data())
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
receive(message, durable: (message["kind"] as? String) != "liveHeartRate")
replyHandler(["accepted": true])
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
if let envelope = userInfo["envelope"] as? Data {
receiveEnvelopeData(envelope, durable: true)
} else {
receive(userInfo, durable: true)
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
receive(applicationContext, durable: true)
}
private static let iso8601: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static func dictionary(from json: String) -> [String: Any]? {
guard let data = json.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let dictionary = object as? [String: Any],
JSONSerialization.isValidJSONObject(dictionary) else { return nil }
return dictionary
}
private static func eventId(in json: String) -> String? {
return dictionary(from: json)?["eventId"] as? String
}
private static func double(_ value: Any?) -> Double? {
if let number = value as? NSNumber { return number.doubleValue }
return value as? Double
}
private func receiveEnvelopeData(_ data: Data, durable: Bool) {
guard let object = try? JSONSerialization.jsonObject(with: data),
let dictionary = object as? [String: Any] else { return }
receive(dictionary, durable: durable || (dictionary["kind"] as? String) != "liveHeartRate")
}
private static func timestamp(_ value: String) -> TimeInterval? {
if let date = iso8601.date(from: value) { return date.timeIntervalSince1970 }
let fallback = ISO8601DateFormatter()
fallback.formatOptions = [.withInternetDateTime]
return fallback.date(from: value)?.timeIntervalSince1970
}
private static func jsonNumber(_ value: Any?) -> Any {
return double(value).map { NSNumber(value: $0) } ?? NSNull()
}
private static func jsonString(_ value: Any?) -> Any {
return (value as? String).map { $0 as Any } ?? NSNull()
}
private static func watchWireEnvelope(kind: String, payload: Any) -> [String: Any]? {
guard JSONSerialization.isValidJSONObject(payload) else { return nil }
return [
"schemaVersion": 1,
"eventId": UUID().uuidString,
"deviceId": UIDeviceIdentifier.current,
"sequence": 0,
"sentAt": iso8601.string(from: Date()),
"kind": kind,
"payload": payload
]
}
}
private enum UIDeviceIdentifier {
static let current: String = {
let key = "HaptikaWatchConnectivity.deviceId.v1"
if let existing = UserDefaults.standard.string(forKey: key) { return existing }
let generated = "iphone-\(UUID().uuidString)"
UserDefaults.standard.set(generated, forKey: key)
return generated
}()
}
@@ -0,0 +1,26 @@
{
"$schema": "https://nitro.margelo.com/nitro.schema.json",
"cxxNamespace": ["haptika", "ppg"],
"ios": {
"iosModuleName": "HaptikaPPG"
},
"android": {
"androidNamespace": ["haptika", "ppg"],
"androidCxxLibName": "HaptikaPPG"
},
"autolinking": {
"HaptikaPPGFrameProcessor": {
"ios": {
"language": "swift",
"implementationClassName": "HybridHaptikaPPGFrameProcessor"
}
},
"HaptikaHealthKit": {
"ios": {
"language": "swift",
"implementationClassName": "HybridHaptikaHealthKit"
}
}
},
"ignorePaths": ["**/node_modules"]
}
@@ -0,0 +1 @@
** linguist-generated=true
@@ -0,0 +1,62 @@
#
# HaptikaPPG+autolinking.rb
# This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
# https://github.com/mrousavy/nitro
# Copyright © Marc Rousavy @ Margelo
#
# This is a Ruby script that adds all files generated by Nitrogen
# to the given podspec.
#
# To use it, add this to your .podspec:
# ```ruby
# Pod::Spec.new do |spec|
# # ...
#
# # Add all files generated by Nitrogen
# load 'nitrogen/generated/ios/HaptikaPPG+autolinking.rb'
# add_nitrogen_files(spec)
# end
# ```
def add_nitrogen_files(spec)
Pod::UI.puts "[NitroModules] 🔥 HaptikaPPG is boosted by nitro!"
spec.dependency "NitroModules"
current_source_files = Array(spec.attributes_hash['source_files'])
spec.source_files = current_source_files + [
# Generated cross-platform specs
"nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}",
# Generated bridges for the cross-platform specs
"nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}",
]
current_public_header_files = Array(spec.attributes_hash['public_header_files'])
spec.public_header_files = current_public_header_files + [
# Generated specs
"nitrogen/generated/shared/**/*.{h,hpp}",
# Swift to C++ bridging helpers
"nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.hpp"
]
current_private_header_files = Array(spec.attributes_hash['private_header_files'])
spec.private_header_files = current_private_header_files + [
# iOS specific specs
"nitrogen/generated/ios/c++/**/*.{h,hpp}",
# Views are framework-specific and should be private
"nitrogen/generated/shared/**/views/**/*"
]
current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {}
spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({
# Use C++ 20
"CLANG_CXX_LANGUAGE_STANDARD" => "c++20",
# Enables C++ <-> Swift interop (by default it's only ObjC)
"SWIFT_OBJC_INTEROP_MODE" => "objcxx",
# Enables stricter modular headers
"DEFINES_MODULE" => "YES",
# Disable auto-generated ObjC header for Swift (Static linkage on Xcode 26.4 breaks here)
"SWIFT_INSTALL_OBJC_HEADER" => "NO",
})
end
@@ -0,0 +1,110 @@
///
/// HaptikaPPG-Swift-Cxx-Bridge.cpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#include "HaptikaPPG-Swift-Cxx-Bridge.hpp"
// Include C++ implementation defined types
#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp"
#include "HybridHaptikaCardiacMirrorSpecSwift.hpp"
#include "HybridHaptikaHealthKitSpecSwift.hpp"
#include "HybridHaptikaPPGFrameProcessorSpecSwift.hpp"
#include <NitroModules/NitroDefines.hpp>
#include <VisionCamera/VisionCamera-Swift-Cxx-Bridge.hpp>
namespace margelo::nitro::haptika::ppg::bridge::swift {
// pragma MARK: std::shared_ptr<margelo::nitro::camera::HybridFrameSpec>
std::shared_ptr<margelo::nitro::camera::HybridFrameSpec> create_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(void* NON_NULL swiftUnsafePointer) noexcept {
// Implemented in VisionCamera
return margelo::nitro::camera::bridge::swift::create_std__shared_ptr_HybridFrameSpec_(swiftUnsafePointer);
}
void* NON_NULL get_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_ cppType) {
// Implemented in VisionCamera
return margelo::nitro::camera::bridge::swift::get_std__shared_ptr_HybridFrameSpec_(cppType);
}
// pragma MARK: std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec>
std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec> create_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(void* NON_NULL swiftUnsafePointer) noexcept {
HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx swiftPart = HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx::fromUnsafe(swiftUnsafePointer);
return std::make_shared<margelo::nitro::haptika::ppg::HybridHaptikaPPGFrameProcessorSpecSwift>(swiftPart);
}
void* NON_NULL get_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ cppType) {
std::shared_ptr<margelo::nitro::haptika::ppg::HybridHaptikaPPGFrameProcessorSpecSwift> swiftWrapper = std::dynamic_pointer_cast<margelo::nitro::haptika::ppg::HybridHaptikaPPGFrameProcessorSpecSwift>(cppType);
#ifdef NITRO_DEBUG
if (swiftWrapper == nullptr) [[unlikely]] {
throw std::runtime_error("Class \"HybridHaptikaPPGFrameProcessorSpec\" is not implemented in Swift!");
}
#endif
HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx& swiftPart = swiftWrapper->getSwiftPart();
return swiftPart.toUnsafe();
}
// pragma MARK: std::function<void(bool /* result */)>
Func_void_bool create_Func_void_bool(void* NON_NULL swiftClosureWrapper) noexcept {
auto swiftClosure = HaptikaPPG::Func_void_bool::fromUnsafe(swiftClosureWrapper);
return [swiftClosure = std::move(swiftClosure)](bool result) mutable -> void {
swiftClosure.call(result);
};
}
// pragma MARK: std::function<void(const std::exception_ptr& /* error */)>
Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept {
auto swiftClosure = HaptikaPPG::Func_void_std__exception_ptr::fromUnsafe(swiftClosureWrapper);
return [swiftClosure = std::move(swiftClosure)](const std::exception_ptr& error) mutable -> void {
swiftClosure.call(error);
};
}
// pragma MARK: std::function<void(const NativeHealthKitContext& /* result */)>
Func_void_NativeHealthKitContext create_Func_void_NativeHealthKitContext(void* NON_NULL swiftClosureWrapper) noexcept {
auto swiftClosure = HaptikaPPG::Func_void_NativeHealthKitContext::fromUnsafe(swiftClosureWrapper);
return [swiftClosure = std::move(swiftClosure)](const NativeHealthKitContext& result) mutable -> void {
swiftClosure.call(result);
};
}
// pragma MARK: std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& /* result */)>
Func_void_std__optional_NativeHeartbeatSeriesSummary_ create_Func_void_std__optional_NativeHeartbeatSeriesSummary_(void* NON_NULL swiftClosureWrapper) noexcept {
auto swiftClosure = HaptikaPPG::Func_void_std__optional_NativeHeartbeatSeriesSummary_::fromUnsafe(swiftClosureWrapper);
return [swiftClosure = std::move(swiftClosure)](const std::optional<NativeHeartbeatSeriesSummary>& result) mutable -> void {
swiftClosure.call(result);
};
}
// pragma MARK: std::shared_ptr<HybridHaptikaHealthKitSpec>
std::shared_ptr<HybridHaptikaHealthKitSpec> create_std__shared_ptr_HybridHaptikaHealthKitSpec_(void* NON_NULL swiftUnsafePointer) noexcept {
HaptikaPPG::HybridHaptikaHealthKitSpec_cxx swiftPart = HaptikaPPG::HybridHaptikaHealthKitSpec_cxx::fromUnsafe(swiftUnsafePointer);
return std::make_shared<margelo::nitro::haptika::ppg::HybridHaptikaHealthKitSpecSwift>(swiftPart);
}
void* NON_NULL get_std__shared_ptr_HybridHaptikaHealthKitSpec_(std__shared_ptr_HybridHaptikaHealthKitSpec_ cppType) {
std::shared_ptr<margelo::nitro::haptika::ppg::HybridHaptikaHealthKitSpecSwift> swiftWrapper = std::dynamic_pointer_cast<margelo::nitro::haptika::ppg::HybridHaptikaHealthKitSpecSwift>(cppType);
#ifdef NITRO_DEBUG
if (swiftWrapper == nullptr) [[unlikely]] {
throw std::runtime_error("Class \"HybridHaptikaHealthKitSpec\" is not implemented in Swift!");
}
#endif
HaptikaPPG::HybridHaptikaHealthKitSpec_cxx& swiftPart = swiftWrapper->getSwiftPart();
return swiftPart.toUnsafe();
}
// pragma MARK: std::shared_ptr<HybridHaptikaCardiacMirrorSpec>
std::shared_ptr<HybridHaptikaCardiacMirrorSpec> create_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(void* NON_NULL swiftUnsafePointer) noexcept {
HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx swiftPart = HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx::fromUnsafe(swiftUnsafePointer);
return std::make_shared<margelo::nitro::haptika::ppg::HybridHaptikaCardiacMirrorSpecSwift>(swiftPart);
}
void* NON_NULL get_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ cppType) {
std::shared_ptr<margelo::nitro::haptika::ppg::HybridHaptikaCardiacMirrorSpecSwift> swiftWrapper = std::dynamic_pointer_cast<margelo::nitro::haptika::ppg::HybridHaptikaCardiacMirrorSpecSwift>(cppType);
#ifdef NITRO_DEBUG
if (swiftWrapper == nullptr) [[unlikely]] {
throw std::runtime_error("Class \"HybridHaptikaCardiacMirrorSpec\" is not implemented in Swift!");
}
#endif
HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx& swiftPart = swiftWrapper->getSwiftPart();
return swiftPart.toUnsafe();
}
} // namespace margelo::nitro::haptika::ppg::bridge::swift
@@ -0,0 +1,489 @@
///
/// HaptikaPPG-Swift-Cxx-Bridge.hpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#pragma once
// Forward declarations of C++ defined types
// Forward declaration of `FingerContactState` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class FingerContactState; }
// Forward declaration of `HealthMetricType` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; }
// Forward declaration of `HybridFrameSpec` to properly resolve imports.
namespace margelo::nitro::camera { class HybridFrameSpec; }
// Forward declaration of `HybridHaptikaCardiacMirrorSpec` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { class HybridHaptikaCardiacMirrorSpec; }
// Forward declaration of `HybridHaptikaHealthKitSpec` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { class HybridHaptikaHealthKitSpec; }
// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { class HybridHaptikaPPGFrameProcessorSpec; }
// Forward declaration of `NativeAcceptedBeat` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; }
// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; }
// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; }
// Forward declaration of `NativeHealthKitContext` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; }
// Forward declaration of `NativeHealthMetricSample` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; }
// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; }
// Forward declaration of `NativePPGProcessingResult` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; }
// Forward declaration of `PPGFrameUpdate` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; }
// Forward declaration of `ThermalState` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class ThermalState; }
// Forward declarations of Swift defined types
// Forward declaration of `HybridFrameSpec_cxx` to properly resolve imports.
namespace VisionCamera { class HybridFrameSpec_cxx; }
// Forward declaration of `HybridHaptikaCardiacMirrorSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaCardiacMirrorSpec_cxx; }
// Forward declaration of `HybridHaptikaHealthKitSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaHealthKitSpec_cxx; }
// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaPPGFrameProcessorSpec_cxx; }
// Include C++ defined types
#include "FingerContactState.hpp"
#include "HealthMetricType.hpp"
#include "HybridHaptikaCardiacMirrorSpec.hpp"
#include "HybridHaptikaHealthKitSpec.hpp"
#include "HybridHaptikaPPGFrameProcessorSpec.hpp"
#include "NativeAcceptedBeat.hpp"
#include "NativeCardiacMirrorHeartbeat.hpp"
#include "NativeCardiacMirrorPatternInfo.hpp"
#include "NativeHealthKitContext.hpp"
#include "NativeHealthMetricSample.hpp"
#include "NativeHeartbeatSeriesSummary.hpp"
#include "NativePPGProcessingResult.hpp"
#include "PPGFrameUpdate.hpp"
#include "ThermalState.hpp"
#include <NitroModules/Promise.hpp>
#include <NitroModules/PromiseHolder.hpp>
#include <NitroModules/Result.hpp>
#include <VisionCamera/HybridFrameSpec.hpp>
#include <exception>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>
/**
* Contains specialized versions of C++ templated types so they can be accessed from Swift,
* as well as helper functions to interact with those C++ types from Swift.
*/
namespace margelo::nitro::haptika::ppg::bridge::swift {
// pragma MARK: std::optional<double>
/**
* Specialized version of `std::optional<double>`.
*/
using std__optional_double_ = std::optional<double>;
inline std::optional<double> create_std__optional_double_(const double& value) noexcept {
return std::optional<double>(value);
}
inline bool has_value_std__optional_double_(const std::optional<double>& optional) noexcept {
return optional.has_value();
}
inline double get_std__optional_double_(const std::optional<double>& optional) noexcept {
return optional.value();
}
// pragma MARK: std::vector<NativeAcceptedBeat>
/**
* Specialized version of `std::vector<NativeAcceptedBeat>`.
*/
using std__vector_NativeAcceptedBeat_ = std::vector<NativeAcceptedBeat>;
inline std::vector<NativeAcceptedBeat> create_std__vector_NativeAcceptedBeat_(size_t size) noexcept {
std::vector<NativeAcceptedBeat> vector;
vector.reserve(size);
return vector;
}
// pragma MARK: std::optional<PPGFrameUpdate>
/**
* Specialized version of `std::optional<PPGFrameUpdate>`.
*/
using std__optional_PPGFrameUpdate_ = std::optional<PPGFrameUpdate>;
inline std::optional<PPGFrameUpdate> create_std__optional_PPGFrameUpdate_(const PPGFrameUpdate& value) noexcept {
return std::optional<PPGFrameUpdate>(value);
}
inline bool has_value_std__optional_PPGFrameUpdate_(const std::optional<PPGFrameUpdate>& optional) noexcept {
return optional.has_value();
}
inline PPGFrameUpdate get_std__optional_PPGFrameUpdate_(const std::optional<PPGFrameUpdate>& optional) noexcept {
return optional.value();
}
// pragma MARK: std::shared_ptr<margelo::nitro::camera::HybridFrameSpec>
/**
* Specialized version of `std::shared_ptr<margelo::nitro::camera::HybridFrameSpec>`.
*/
using std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_ = std::shared_ptr<margelo::nitro::camera::HybridFrameSpec>;
std::shared_ptr<margelo::nitro::camera::HybridFrameSpec> create_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(void* NON_NULL swiftUnsafePointer) noexcept;
void* NON_NULL get_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_ cppType);
// pragma MARK: std::weak_ptr<margelo::nitro::camera::HybridFrameSpec>
using std__weak_ptr_margelo__nitro__camera__HybridFrameSpec_ = std::weak_ptr<margelo::nitro::camera::HybridFrameSpec>;
inline std__weak_ptr_margelo__nitro__camera__HybridFrameSpec_ weakify_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(const std::shared_ptr<margelo::nitro::camera::HybridFrameSpec>& strong) noexcept { return strong; }
// pragma MARK: std::optional<std::string>
/**
* Specialized version of `std::optional<std::string>`.
*/
using std__optional_std__string_ = std::optional<std::string>;
inline std::optional<std::string> create_std__optional_std__string_(const std::string& value) noexcept {
return std::optional<std::string>(value);
}
inline bool has_value_std__optional_std__string_(const std::optional<std::string>& optional) noexcept {
return optional.has_value();
}
inline std::string get_std__optional_std__string_(const std::optional<std::string>& optional) noexcept {
return optional.value();
}
// pragma MARK: std::vector<std::string>
/**
* Specialized version of `std::vector<std::string>`.
*/
using std__vector_std__string_ = std::vector<std::string>;
inline std::vector<std::string> create_std__vector_std__string_(size_t size) noexcept {
std::vector<std::string> vector;
vector.reserve(size);
return vector;
}
// pragma MARK: std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec>
/**
* Specialized version of `std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec>`.
*/
using std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ = std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec>;
std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec> create_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(void* NON_NULL swiftUnsafePointer) noexcept;
void* NON_NULL get_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ cppType);
// pragma MARK: std::weak_ptr<HybridHaptikaPPGFrameProcessorSpec>
using std__weak_ptr_HybridHaptikaPPGFrameProcessorSpec_ = std::weak_ptr<HybridHaptikaPPGFrameProcessorSpec>;
inline std__weak_ptr_HybridHaptikaPPGFrameProcessorSpec_ weakify_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(const std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec>& strong) noexcept { return strong; }
// pragma MARK: Result<void>
using Result_void_ = Result<void>;
inline Result_void_ create_Result_void_() noexcept {
return Result<void>::withValue();
}
inline Result_void_ create_Result_void_(const std::exception_ptr& error) noexcept {
return Result<void>::withError(error);
}
// pragma MARK: Result<std::optional<PPGFrameUpdate>>
using Result_std__optional_PPGFrameUpdate__ = Result<std::optional<PPGFrameUpdate>>;
inline Result_std__optional_PPGFrameUpdate__ create_Result_std__optional_PPGFrameUpdate__(const std::optional<PPGFrameUpdate>& value) noexcept {
return Result<std::optional<PPGFrameUpdate>>::withValue(value);
}
inline Result_std__optional_PPGFrameUpdate__ create_Result_std__optional_PPGFrameUpdate__(const std::exception_ptr& error) noexcept {
return Result<std::optional<PPGFrameUpdate>>::withError(error);
}
// pragma MARK: Result<NativePPGProcessingResult>
using Result_NativePPGProcessingResult_ = Result<NativePPGProcessingResult>;
inline Result_NativePPGProcessingResult_ create_Result_NativePPGProcessingResult_(const NativePPGProcessingResult& value) noexcept {
return Result<NativePPGProcessingResult>::withValue(value);
}
inline Result_NativePPGProcessingResult_ create_Result_NativePPGProcessingResult_(const std::exception_ptr& error) noexcept {
return Result<NativePPGProcessingResult>::withError(error);
}
// pragma MARK: Result<ThermalState>
using Result_ThermalState_ = Result<ThermalState>;
inline Result_ThermalState_ create_Result_ThermalState_(ThermalState value) noexcept {
return Result<ThermalState>::withValue(std::move(value));
}
inline Result_ThermalState_ create_Result_ThermalState_(const std::exception_ptr& error) noexcept {
return Result<ThermalState>::withError(error);
}
// pragma MARK: std::shared_ptr<Promise<bool>>
/**
* Specialized version of `std::shared_ptr<Promise<bool>>`.
*/
using std__shared_ptr_Promise_bool__ = std::shared_ptr<Promise<bool>>;
inline std::shared_ptr<Promise<bool>> create_std__shared_ptr_Promise_bool__() noexcept {
return Promise<bool>::create();
}
inline PromiseHolder<bool> wrap_std__shared_ptr_Promise_bool__(std::shared_ptr<Promise<bool>> promise) noexcept {
return PromiseHolder<bool>(std::move(promise));
}
// pragma MARK: std::function<void(bool /* result */)>
/**
* Specialized version of `std::function<void(bool)>`.
*/
using Func_void_bool = std::function<void(bool /* result */)>;
/**
* Wrapper class for a `std::function<void(bool / * result * /)>`, this can be used from Swift.
*/
class Func_void_bool_Wrapper final {
public:
explicit Func_void_bool_Wrapper(std::function<void(bool /* result */)>&& func): _function(std::make_unique<std::function<void(bool /* result */)>>(std::move(func))) {}
inline void call(bool result) const noexcept {
_function->operator()(result);
}
private:
std::unique_ptr<std::function<void(bool /* result */)>> _function;
} SWIFT_NONCOPYABLE;
Func_void_bool create_Func_void_bool(void* NON_NULL swiftClosureWrapper) noexcept;
inline Func_void_bool_Wrapper wrap_Func_void_bool(Func_void_bool value) noexcept {
return Func_void_bool_Wrapper(std::move(value));
}
// pragma MARK: std::function<void(const std::exception_ptr& /* error */)>
/**
* Specialized version of `std::function<void(const std::exception_ptr&)>`.
*/
using Func_void_std__exception_ptr = std::function<void(const std::exception_ptr& /* error */)>;
/**
* Wrapper class for a `std::function<void(const std::exception_ptr& / * error * /)>`, this can be used from Swift.
*/
class Func_void_std__exception_ptr_Wrapper final {
public:
explicit Func_void_std__exception_ptr_Wrapper(std::function<void(const std::exception_ptr& /* error */)>&& func): _function(std::make_unique<std::function<void(const std::exception_ptr& /* error */)>>(std::move(func))) {}
inline void call(std::exception_ptr error) const noexcept {
_function->operator()(error);
}
private:
std::unique_ptr<std::function<void(const std::exception_ptr& /* error */)>> _function;
} SWIFT_NONCOPYABLE;
Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept;
inline Func_void_std__exception_ptr_Wrapper wrap_Func_void_std__exception_ptr(Func_void_std__exception_ptr value) noexcept {
return Func_void_std__exception_ptr_Wrapper(std::move(value));
}
// pragma MARK: std::optional<NativeHealthMetricSample>
/**
* Specialized version of `std::optional<NativeHealthMetricSample>`.
*/
using std__optional_NativeHealthMetricSample_ = std::optional<NativeHealthMetricSample>;
inline std::optional<NativeHealthMetricSample> create_std__optional_NativeHealthMetricSample_(const NativeHealthMetricSample& value) noexcept {
return std::optional<NativeHealthMetricSample>(value);
}
inline bool has_value_std__optional_NativeHealthMetricSample_(const std::optional<NativeHealthMetricSample>& optional) noexcept {
return optional.has_value();
}
inline NativeHealthMetricSample get_std__optional_NativeHealthMetricSample_(const std::optional<NativeHealthMetricSample>& optional) noexcept {
return optional.value();
}
// pragma MARK: std::vector<NativeHealthMetricSample>
/**
* Specialized version of `std::vector<NativeHealthMetricSample>`.
*/
using std__vector_NativeHealthMetricSample_ = std::vector<NativeHealthMetricSample>;
inline std::vector<NativeHealthMetricSample> create_std__vector_NativeHealthMetricSample_(size_t size) noexcept {
std::vector<NativeHealthMetricSample> vector;
vector.reserve(size);
return vector;
}
// pragma MARK: std::shared_ptr<Promise<NativeHealthKitContext>>
/**
* Specialized version of `std::shared_ptr<Promise<NativeHealthKitContext>>`.
*/
using std__shared_ptr_Promise_NativeHealthKitContext__ = std::shared_ptr<Promise<NativeHealthKitContext>>;
inline std::shared_ptr<Promise<NativeHealthKitContext>> create_std__shared_ptr_Promise_NativeHealthKitContext__() noexcept {
return Promise<NativeHealthKitContext>::create();
}
inline PromiseHolder<NativeHealthKitContext> wrap_std__shared_ptr_Promise_NativeHealthKitContext__(std::shared_ptr<Promise<NativeHealthKitContext>> promise) noexcept {
return PromiseHolder<NativeHealthKitContext>(std::move(promise));
}
// pragma MARK: std::function<void(const NativeHealthKitContext& /* result */)>
/**
* Specialized version of `std::function<void(const NativeHealthKitContext&)>`.
*/
using Func_void_NativeHealthKitContext = std::function<void(const NativeHealthKitContext& /* result */)>;
/**
* Wrapper class for a `std::function<void(const NativeHealthKitContext& / * result * /)>`, this can be used from Swift.
*/
class Func_void_NativeHealthKitContext_Wrapper final {
public:
explicit Func_void_NativeHealthKitContext_Wrapper(std::function<void(const NativeHealthKitContext& /* result */)>&& func): _function(std::make_unique<std::function<void(const NativeHealthKitContext& /* result */)>>(std::move(func))) {}
inline void call(NativeHealthKitContext result) const noexcept {
_function->operator()(result);
}
private:
std::unique_ptr<std::function<void(const NativeHealthKitContext& /* result */)>> _function;
} SWIFT_NONCOPYABLE;
Func_void_NativeHealthKitContext create_Func_void_NativeHealthKitContext(void* NON_NULL swiftClosureWrapper) noexcept;
inline Func_void_NativeHealthKitContext_Wrapper wrap_Func_void_NativeHealthKitContext(Func_void_NativeHealthKitContext value) noexcept {
return Func_void_NativeHealthKitContext_Wrapper(std::move(value));
}
// pragma MARK: std::vector<double>
/**
* Specialized version of `std::vector<double>`.
*/
using std__vector_double_ = std::vector<double>;
inline std::vector<double> create_std__vector_double_(size_t size) noexcept {
std::vector<double> vector;
vector.reserve(size);
return vector;
}
// pragma MARK: std::optional<NativeHeartbeatSeriesSummary>
/**
* Specialized version of `std::optional<NativeHeartbeatSeriesSummary>`.
*/
using std__optional_NativeHeartbeatSeriesSummary_ = std::optional<NativeHeartbeatSeriesSummary>;
inline std::optional<NativeHeartbeatSeriesSummary> create_std__optional_NativeHeartbeatSeriesSummary_(const NativeHeartbeatSeriesSummary& value) noexcept {
return std::optional<NativeHeartbeatSeriesSummary>(value);
}
inline bool has_value_std__optional_NativeHeartbeatSeriesSummary_(const std::optional<NativeHeartbeatSeriesSummary>& optional) noexcept {
return optional.has_value();
}
inline NativeHeartbeatSeriesSummary get_std__optional_NativeHeartbeatSeriesSummary_(const std::optional<NativeHeartbeatSeriesSummary>& optional) noexcept {
return optional.value();
}
// pragma MARK: std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>
/**
* Specialized version of `std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>`.
*/
using std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___ = std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>;
inline std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>> create_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___() noexcept {
return Promise<std::optional<NativeHeartbeatSeriesSummary>>::create();
}
inline PromiseHolder<std::optional<NativeHeartbeatSeriesSummary>> wrap_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___(std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>> promise) noexcept {
return PromiseHolder<std::optional<NativeHeartbeatSeriesSummary>>(std::move(promise));
}
// pragma MARK: std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& /* result */)>
/**
* Specialized version of `std::function<void(const std::optional<NativeHeartbeatSeriesSummary>&)>`.
*/
using Func_void_std__optional_NativeHeartbeatSeriesSummary_ = std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& /* result */)>;
/**
* Wrapper class for a `std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& / * result * /)>`, this can be used from Swift.
*/
class Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper final {
public:
explicit Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper(std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& /* result */)>&& func): _function(std::make_unique<std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& /* result */)>>(std::move(func))) {}
inline void call(std::optional<NativeHeartbeatSeriesSummary> result) const noexcept {
_function->operator()(result);
}
private:
std::unique_ptr<std::function<void(const std::optional<NativeHeartbeatSeriesSummary>& /* result */)>> _function;
} SWIFT_NONCOPYABLE;
Func_void_std__optional_NativeHeartbeatSeriesSummary_ create_Func_void_std__optional_NativeHeartbeatSeriesSummary_(void* NON_NULL swiftClosureWrapper) noexcept;
inline Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper wrap_Func_void_std__optional_NativeHeartbeatSeriesSummary_(Func_void_std__optional_NativeHeartbeatSeriesSummary_ value) noexcept {
return Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper(std::move(value));
}
// pragma MARK: std::shared_ptr<HybridHaptikaHealthKitSpec>
/**
* Specialized version of `std::shared_ptr<HybridHaptikaHealthKitSpec>`.
*/
using std__shared_ptr_HybridHaptikaHealthKitSpec_ = std::shared_ptr<HybridHaptikaHealthKitSpec>;
std::shared_ptr<HybridHaptikaHealthKitSpec> create_std__shared_ptr_HybridHaptikaHealthKitSpec_(void* NON_NULL swiftUnsafePointer) noexcept;
void* NON_NULL get_std__shared_ptr_HybridHaptikaHealthKitSpec_(std__shared_ptr_HybridHaptikaHealthKitSpec_ cppType);
// pragma MARK: std::weak_ptr<HybridHaptikaHealthKitSpec>
using std__weak_ptr_HybridHaptikaHealthKitSpec_ = std::weak_ptr<HybridHaptikaHealthKitSpec>;
inline std__weak_ptr_HybridHaptikaHealthKitSpec_ weakify_std__shared_ptr_HybridHaptikaHealthKitSpec_(const std::shared_ptr<HybridHaptikaHealthKitSpec>& strong) noexcept { return strong; }
// pragma MARK: Result<bool>
using Result_bool_ = Result<bool>;
inline Result_bool_ create_Result_bool_(bool value) noexcept {
return Result<bool>::withValue(std::move(value));
}
inline Result_bool_ create_Result_bool_(const std::exception_ptr& error) noexcept {
return Result<bool>::withError(error);
}
// pragma MARK: Result<std::shared_ptr<Promise<bool>>>
using Result_std__shared_ptr_Promise_bool___ = Result<std::shared_ptr<Promise<bool>>>;
inline Result_std__shared_ptr_Promise_bool___ create_Result_std__shared_ptr_Promise_bool___(const std::shared_ptr<Promise<bool>>& value) noexcept {
return Result<std::shared_ptr<Promise<bool>>>::withValue(value);
}
inline Result_std__shared_ptr_Promise_bool___ create_Result_std__shared_ptr_Promise_bool___(const std::exception_ptr& error) noexcept {
return Result<std::shared_ptr<Promise<bool>>>::withError(error);
}
// pragma MARK: Result<std::shared_ptr<Promise<NativeHealthKitContext>>>
using Result_std__shared_ptr_Promise_NativeHealthKitContext___ = Result<std::shared_ptr<Promise<NativeHealthKitContext>>>;
inline Result_std__shared_ptr_Promise_NativeHealthKitContext___ create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(const std::shared_ptr<Promise<NativeHealthKitContext>>& value) noexcept {
return Result<std::shared_ptr<Promise<NativeHealthKitContext>>>::withValue(value);
}
inline Result_std__shared_ptr_Promise_NativeHealthKitContext___ create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(const std::exception_ptr& error) noexcept {
return Result<std::shared_ptr<Promise<NativeHealthKitContext>>>::withError(error);
}
// pragma MARK: Result<std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>>
using Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ = Result<std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>>;
inline Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(const std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>& value) noexcept {
return Result<std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>>::withValue(value);
}
inline Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(const std::exception_ptr& error) noexcept {
return Result<std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>>>::withError(error);
}
// pragma MARK: std::optional<NativeCardiacMirrorHeartbeat>
/**
* Specialized version of `std::optional<NativeCardiacMirrorHeartbeat>`.
*/
using std__optional_NativeCardiacMirrorHeartbeat_ = std::optional<NativeCardiacMirrorHeartbeat>;
inline std::optional<NativeCardiacMirrorHeartbeat> create_std__optional_NativeCardiacMirrorHeartbeat_(const NativeCardiacMirrorHeartbeat& value) noexcept {
return std::optional<NativeCardiacMirrorHeartbeat>(value);
}
inline bool has_value_std__optional_NativeCardiacMirrorHeartbeat_(const std::optional<NativeCardiacMirrorHeartbeat>& optional) noexcept {
return optional.has_value();
}
inline NativeCardiacMirrorHeartbeat get_std__optional_NativeCardiacMirrorHeartbeat_(const std::optional<NativeCardiacMirrorHeartbeat>& optional) noexcept {
return optional.value();
}
// pragma MARK: std::shared_ptr<HybridHaptikaCardiacMirrorSpec>
/**
* Specialized version of `std::shared_ptr<HybridHaptikaCardiacMirrorSpec>`.
*/
using std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ = std::shared_ptr<HybridHaptikaCardiacMirrorSpec>;
std::shared_ptr<HybridHaptikaCardiacMirrorSpec> create_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(void* NON_NULL swiftUnsafePointer) noexcept;
void* NON_NULL get_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ cppType);
// pragma MARK: std::weak_ptr<HybridHaptikaCardiacMirrorSpec>
using std__weak_ptr_HybridHaptikaCardiacMirrorSpec_ = std::weak_ptr<HybridHaptikaCardiacMirrorSpec>;
inline std__weak_ptr_HybridHaptikaCardiacMirrorSpec_ weakify_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(const std::shared_ptr<HybridHaptikaCardiacMirrorSpec>& strong) noexcept { return strong; }
// pragma MARK: Result<NativeCardiacMirrorPatternInfo>
using Result_NativeCardiacMirrorPatternInfo_ = Result<NativeCardiacMirrorPatternInfo>;
inline Result_NativeCardiacMirrorPatternInfo_ create_Result_NativeCardiacMirrorPatternInfo_(const NativeCardiacMirrorPatternInfo& value) noexcept {
return Result<NativeCardiacMirrorPatternInfo>::withValue(value);
}
inline Result_NativeCardiacMirrorPatternInfo_ create_Result_NativeCardiacMirrorPatternInfo_(const std::exception_ptr& error) noexcept {
return Result<NativeCardiacMirrorPatternInfo>::withError(error);
}
// pragma MARK: Result<std::optional<NativeCardiacMirrorHeartbeat>>
using Result_std__optional_NativeCardiacMirrorHeartbeat__ = Result<std::optional<NativeCardiacMirrorHeartbeat>>;
inline Result_std__optional_NativeCardiacMirrorHeartbeat__ create_Result_std__optional_NativeCardiacMirrorHeartbeat__(const std::optional<NativeCardiacMirrorHeartbeat>& value) noexcept {
return Result<std::optional<NativeCardiacMirrorHeartbeat>>::withValue(value);
}
inline Result_std__optional_NativeCardiacMirrorHeartbeat__ create_Result_std__optional_NativeCardiacMirrorHeartbeat__(const std::exception_ptr& error) noexcept {
return Result<std::optional<NativeCardiacMirrorHeartbeat>>::withError(error);
}
// pragma MARK: Result<double>
using Result_double_ = Result<double>;
inline Result_double_ create_Result_double_(double value) noexcept {
return Result<double>::withValue(std::move(value));
}
inline Result_double_ create_Result_double_(const std::exception_ptr& error) noexcept {
return Result<double>::withError(error);
}
} // namespace margelo::nitro::haptika::ppg::bridge::swift
@@ -0,0 +1,110 @@
///
/// HaptikaPPG-Swift-Cxx-Umbrella.hpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#pragma once
// Forward declarations of C++ defined types
// Forward declaration of `FingerContactState` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class FingerContactState; }
// Forward declaration of `HealthMetricType` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; }
// Forward declaration of `HybridFrameSpec` to properly resolve imports.
namespace margelo::nitro::camera { class HybridFrameSpec; }
// Forward declaration of `HybridHaptikaCardiacMirrorSpec` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { class HybridHaptikaCardiacMirrorSpec; }
// Forward declaration of `HybridHaptikaHealthKitSpec` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { class HybridHaptikaHealthKitSpec; }
// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { class HybridHaptikaPPGFrameProcessorSpec; }
// Forward declaration of `NativeAcceptedBeat` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; }
// Forward declaration of `NativeBPMEstimate` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeBPMEstimate; }
// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; }
// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; }
// Forward declaration of `NativeHealthKitContext` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; }
// Forward declaration of `NativeHealthMetricSample` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; }
// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; }
// Forward declaration of `NativePPGConfiguration` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePPGConfiguration; }
// Forward declaration of `NativePPGProcessingResult` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; }
// Forward declaration of `NativePRVMetrics` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePRVMetrics; }
// Forward declaration of `NativeSignalQualityResult` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeSignalQualityResult; }
// Forward declaration of `PPGFrameUpdate` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; }
// Forward declaration of `PPGSessionPurpose` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class PPGSessionPurpose; }
// Forward declaration of `ThermalState` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class ThermalState; }
// Include C++ defined types
#include "FingerContactState.hpp"
#include "HealthMetricType.hpp"
#include "HybridHaptikaCardiacMirrorSpec.hpp"
#include "HybridHaptikaHealthKitSpec.hpp"
#include "HybridHaptikaPPGFrameProcessorSpec.hpp"
#include "NativeAcceptedBeat.hpp"
#include "NativeBPMEstimate.hpp"
#include "NativeCardiacMirrorHeartbeat.hpp"
#include "NativeCardiacMirrorPatternInfo.hpp"
#include "NativeHealthKitContext.hpp"
#include "NativeHealthMetricSample.hpp"
#include "NativeHeartbeatSeriesSummary.hpp"
#include "NativePPGConfiguration.hpp"
#include "NativePPGProcessingResult.hpp"
#include "NativePRVMetrics.hpp"
#include "NativeSignalQualityResult.hpp"
#include "PPGFrameUpdate.hpp"
#include "PPGSessionPurpose.hpp"
#include "ThermalState.hpp"
#include <NitroModules/Promise.hpp>
#include <NitroModules/Result.hpp>
#include <VisionCamera/HybridFrameSpec.hpp>
#include <exception>
#include <memory>
#include <optional>
#include <string>
#include <vector>
// C++ helpers for Swift
#include "HaptikaPPG-Swift-Cxx-Bridge.hpp"
// Common C++ types used in Swift
#include <NitroModules/ArrayBufferHolder.hpp>
#include <NitroModules/AnyMapUtils.hpp>
#include <NitroModules/RuntimeError.hpp>
#include <NitroModules/DateToChronoDate.hpp>
// Forward declarations of Swift defined types
// Forward declaration of `HybridFrameSpec_cxx` to properly resolve imports.
namespace VisionCamera { class HybridFrameSpec_cxx; }
// Forward declaration of `HybridHaptikaCardiacMirrorSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaCardiacMirrorSpec_cxx; }
// Forward declaration of `HybridHaptikaHealthKitSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaHealthKitSpec_cxx; }
// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaPPGFrameProcessorSpec_cxx; }
// Include Swift defined types
#if __has_include("HaptikaPPG-Swift.h")
// This header is generated by Xcode/Swift on every app build.
// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "HaptikaPPG".
#include "HaptikaPPG-Swift.h"
// Same as above, but used when building with frameworks (`use_frameworks`)
#elif __has_include(<HaptikaPPG/HaptikaPPG-Swift.h>)
#include <HaptikaPPG/HaptikaPPG-Swift.h>
#else
#error HaptikaPPG's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "HaptikaPPG", and try building the app first.
#endif
@@ -0,0 +1,49 @@
///
/// HaptikaPPGAutolinking.mm
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#import <Foundation/Foundation.h>
#import <NitroModules/HybridObjectRegistry.hpp>
#import "HaptikaPPG-Swift-Cxx-Umbrella.hpp"
#import <type_traits>
#include "HybridHaptikaPPGFrameProcessorSpecSwift.hpp"
#include "HybridHaptikaHealthKitSpecSwift.hpp"
#include "HybridHaptikaCardiacMirrorSpecSwift.hpp"
@interface HaptikaPPGAutolinking : NSObject
@end
@implementation HaptikaPPGAutolinking
+ (void) load {
using namespace margelo::nitro;
using namespace margelo::nitro::haptika::ppg;
HybridObjectRegistry::registerHybridObjectConstructor(
"HaptikaPPGFrameProcessor",
[]() -> std::shared_ptr<HybridObject> {
std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec> hybridObject = HaptikaPPG::HaptikaPPGAutolinking::createHaptikaPPGFrameProcessor();
return hybridObject;
}
);
HybridObjectRegistry::registerHybridObjectConstructor(
"HaptikaHealthKit",
[]() -> std::shared_ptr<HybridObject> {
std::shared_ptr<HybridHaptikaHealthKitSpec> hybridObject = HaptikaPPG::HaptikaPPGAutolinking::createHaptikaHealthKit();
return hybridObject;
}
);
HybridObjectRegistry::registerHybridObjectConstructor(
"HaptikaCardiacMirror",
[]() -> std::shared_ptr<HybridObject> {
std::shared_ptr<HybridHaptikaCardiacMirrorSpec> hybridObject = HaptikaPPG::HaptikaPPGAutolinking::createHaptikaCardiacMirror();
return hybridObject;
}
);
}
@end
@@ -0,0 +1,50 @@
///
/// HaptikaPPGAutolinking.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
// TODO: Use empty enums once Swift supports exporting them as namespaces
// See: https://github.com/swiftlang/swift/pull/83616
public final class HaptikaPPGAutolinking {
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
public static func createHaptikaPPGFrameProcessor() -> bridge.std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ {
let hybridObject = HybridHaptikaPPGFrameProcessor()
return { () -> bridge.std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ in
let __cxxWrapped = hybridObject.getCxxWrapper()
return __cxxWrapped.getCxxPart()
}()
}
public static func isHaptikaPPGFrameProcessorRecyclable() -> Bool {
return HybridHaptikaPPGFrameProcessor.self is any RecyclableView.Type
}
public static func createHaptikaHealthKit() -> bridge.std__shared_ptr_HybridHaptikaHealthKitSpec_ {
let hybridObject = HybridHaptikaHealthKit()
return { () -> bridge.std__shared_ptr_HybridHaptikaHealthKitSpec_ in
let __cxxWrapped = hybridObject.getCxxWrapper()
return __cxxWrapped.getCxxPart()
}()
}
public static func isHaptikaHealthKitRecyclable() -> Bool {
return HybridHaptikaHealthKit.self is any RecyclableView.Type
}
public static func createHaptikaCardiacMirror() -> bridge.std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ {
let hybridObject = HybridHaptikaCardiacMirror()
return { () -> bridge.std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ in
let __cxxWrapped = hybridObject.getCxxWrapper()
return __cxxWrapped.getCxxPart()
}()
}
public static func isHaptikaCardiacMirrorRecyclable() -> Bool {
return HybridHaptikaCardiacMirror.self is any RecyclableView.Type
}
}
@@ -0,0 +1,11 @@
///
/// HybridHaptikaCardiacMirrorSpecSwift.cpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#include "HybridHaptikaCardiacMirrorSpecSwift.hpp"
namespace margelo::nitro::haptika::ppg {
} // namespace margelo::nitro::haptika::ppg
@@ -0,0 +1,150 @@
///
/// HybridHaptikaCardiacMirrorSpecSwift.hpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#pragma once
#include "HybridHaptikaCardiacMirrorSpec.hpp"
// Forward declaration of `HybridHaptikaCardiacMirrorSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaCardiacMirrorSpec_cxx; }
// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; }
// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; }
#include "NativeCardiacMirrorPatternInfo.hpp"
#include <string>
#include "NativeCardiacMirrorHeartbeat.hpp"
#include <optional>
#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp"
namespace margelo::nitro::haptika::ppg {
/**
* The C++ part of HybridHaptikaCardiacMirrorSpec_cxx.swift.
*
* HybridHaptikaCardiacMirrorSpecSwift (C++) accesses HybridHaptikaCardiacMirrorSpec_cxx (Swift), and might
* contain some additional bridging code for C++ <> Swift interop.
*
* Since this obviously introduces an overhead, I hope at some point in
* the future, HybridHaptikaCardiacMirrorSpec_cxx can directly inherit from the C++ class HybridHaptikaCardiacMirrorSpec
* to simplify the whole structure and memory management.
*/
class HybridHaptikaCardiacMirrorSpecSwift: public virtual HybridHaptikaCardiacMirrorSpec {
public:
// Constructor from a Swift instance
explicit HybridHaptikaCardiacMirrorSpecSwift(const HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx& swiftPart):
HybridObject(HybridHaptikaCardiacMirrorSpec::TAG),
_swiftPart(swiftPart) { }
public:
// Get the Swift part
inline HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx& getSwiftPart() noexcept {
return _swiftPart;
}
public:
inline size_t getExternalMemorySize() noexcept override {
return _swiftPart.getMemorySize();
}
bool equals(const std::shared_ptr<HybridObject>& other) override {
if (auto otherCast = std::dynamic_pointer_cast<HybridHaptikaCardiacMirrorSpecSwift>(other)) {
return _swiftPart.equals(otherCast->_swiftPart);
}
return false;
}
void dispose() noexcept override {
_swiftPart.dispose();
}
std::string toString() override {
return _swiftPart.toString();
}
public:
// Properties
public:
// Methods
inline bool isSupported() override {
auto __result = _swiftPart.isSupported();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline NativeCardiacMirrorPatternInfo patternInfo() override {
auto __result = _swiftPart.patternInfo();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline void start(double targetBpm) override {
auto __result = _swiftPart.start(std::forward<decltype(targetBpm)>(targetBpm));
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
inline void pulse() override {
auto __result = _swiftPart.pulse();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
inline void stop() override {
auto __result = _swiftPart.stop();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
inline void startWatchSession() override {
auto __result = _swiftPart.startWatchSession();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
inline bool watchReachable() override {
auto __result = _swiftPart.watchReachable();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline std::optional<NativeCardiacMirrorHeartbeat> latestHeartbeat() override {
auto __result = _swiftPart.latestHeartbeat();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline double playRegulationProtocol(const std::string& kind) override {
auto __result = _swiftPart.playRegulationProtocol(kind);
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline void stopRegulationProtocol() override {
auto __result = _swiftPart.stopRegulationProtocol();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
private:
HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx _swiftPart;
};
} // namespace margelo::nitro::haptika::ppg
@@ -0,0 +1,11 @@
///
/// HybridHaptikaHealthKitSpecSwift.cpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#include "HybridHaptikaHealthKitSpecSwift.hpp"
namespace margelo::nitro::haptika::ppg {
} // namespace margelo::nitro::haptika::ppg
@@ -0,0 +1,120 @@
///
/// HybridHaptikaHealthKitSpecSwift.hpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#pragma once
#include "HybridHaptikaHealthKitSpec.hpp"
// Forward declaration of `HybridHaptikaHealthKitSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaHealthKitSpec_cxx; }
// Forward declaration of `NativeHealthKitContext` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; }
// Forward declaration of `NativeHealthMetricSample` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; }
// Forward declaration of `HealthMetricType` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; }
// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; }
#include <NitroModules/Promise.hpp>
#include "NativeHealthKitContext.hpp"
#include "NativeHealthMetricSample.hpp"
#include <optional>
#include "HealthMetricType.hpp"
#include <string>
#include <vector>
#include "NativeHeartbeatSeriesSummary.hpp"
#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp"
namespace margelo::nitro::haptika::ppg {
/**
* The C++ part of HybridHaptikaHealthKitSpec_cxx.swift.
*
* HybridHaptikaHealthKitSpecSwift (C++) accesses HybridHaptikaHealthKitSpec_cxx (Swift), and might
* contain some additional bridging code for C++ <> Swift interop.
*
* Since this obviously introduces an overhead, I hope at some point in
* the future, HybridHaptikaHealthKitSpec_cxx can directly inherit from the C++ class HybridHaptikaHealthKitSpec
* to simplify the whole structure and memory management.
*/
class HybridHaptikaHealthKitSpecSwift: public virtual HybridHaptikaHealthKitSpec {
public:
// Constructor from a Swift instance
explicit HybridHaptikaHealthKitSpecSwift(const HaptikaPPG::HybridHaptikaHealthKitSpec_cxx& swiftPart):
HybridObject(HybridHaptikaHealthKitSpec::TAG),
_swiftPart(swiftPart) { }
public:
// Get the Swift part
inline HaptikaPPG::HybridHaptikaHealthKitSpec_cxx& getSwiftPart() noexcept {
return _swiftPart;
}
public:
inline size_t getExternalMemorySize() noexcept override {
return _swiftPart.getMemorySize();
}
bool equals(const std::shared_ptr<HybridObject>& other) override {
if (auto otherCast = std::dynamic_pointer_cast<HybridHaptikaHealthKitSpecSwift>(other)) {
return _swiftPart.equals(otherCast->_swiftPart);
}
return false;
}
void dispose() noexcept override {
_swiftPart.dispose();
}
std::string toString() override {
return _swiftPart.toString();
}
public:
// Properties
public:
// Methods
inline bool isAvailable() override {
auto __result = _swiftPart.isAvailable();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline std::shared_ptr<Promise<bool>> requestReadAuthorization() override {
auto __result = _swiftPart.requestReadAuthorization();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline std::shared_ptr<Promise<NativeHealthKitContext>> fetchContext(double lookbackDays) override {
auto __result = _swiftPart.fetchContext(std::forward<decltype(lookbackDays)>(lookbackDays));
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline std::shared_ptr<Promise<std::optional<NativeHeartbeatSeriesSummary>>> fetchLatestHeartbeatSeries() override {
auto __result = _swiftPart.fetchLatestHeartbeatSeries();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
private:
HaptikaPPG::HybridHaptikaHealthKitSpec_cxx _swiftPart;
};
} // namespace margelo::nitro::haptika::ppg
@@ -0,0 +1,11 @@
///
/// HybridHaptikaPPGFrameProcessorSpecSwift.cpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#include "HybridHaptikaPPGFrameProcessorSpecSwift.hpp"
namespace margelo::nitro::haptika::ppg {
} // namespace margelo::nitro::haptika::ppg
@@ -0,0 +1,145 @@
///
/// HybridHaptikaPPGFrameProcessorSpecSwift.hpp
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
#pragma once
#include "HybridHaptikaPPGFrameProcessorSpec.hpp"
// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec_cxx` to properly resolve imports.
namespace HaptikaPPG { class HybridHaptikaPPGFrameProcessorSpec_cxx; }
// Forward declaration of `NativePPGConfiguration` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePPGConfiguration; }
// Forward declaration of `PPGSessionPurpose` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class PPGSessionPurpose; }
// Forward declaration of `PPGFrameUpdate` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; }
// Forward declaration of `FingerContactState` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class FingerContactState; }
// Forward declaration of `ThermalState` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { enum class ThermalState; }
// Forward declaration of `NativeAcceptedBeat` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; }
// Forward declaration of `HybridFrameSpec` to properly resolve imports.
namespace margelo::nitro::camera { class HybridFrameSpec; }
// Forward declaration of `NativePPGProcessingResult` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; }
// Forward declaration of `NativeBPMEstimate` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeBPMEstimate; }
// Forward declaration of `NativePRVMetrics` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativePRVMetrics; }
// Forward declaration of `NativeSignalQualityResult` to properly resolve imports.
namespace margelo::nitro::haptika::ppg { struct NativeSignalQualityResult; }
#include "NativePPGConfiguration.hpp"
#include "PPGSessionPurpose.hpp"
#include "PPGFrameUpdate.hpp"
#include <optional>
#include "FingerContactState.hpp"
#include "ThermalState.hpp"
#include "NativeAcceptedBeat.hpp"
#include <vector>
#include <memory>
#include <VisionCamera/HybridFrameSpec.hpp>
#include "NativePPGProcessingResult.hpp"
#include <string>
#include "NativeBPMEstimate.hpp"
#include "NativePRVMetrics.hpp"
#include "NativeSignalQualityResult.hpp"
#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp"
namespace margelo::nitro::haptika::ppg {
/**
* The C++ part of HybridHaptikaPPGFrameProcessorSpec_cxx.swift.
*
* HybridHaptikaPPGFrameProcessorSpecSwift (C++) accesses HybridHaptikaPPGFrameProcessorSpec_cxx (Swift), and might
* contain some additional bridging code for C++ <> Swift interop.
*
* Since this obviously introduces an overhead, I hope at some point in
* the future, HybridHaptikaPPGFrameProcessorSpec_cxx can directly inherit from the C++ class HybridHaptikaPPGFrameProcessorSpec
* to simplify the whole structure and memory management.
*/
class HybridHaptikaPPGFrameProcessorSpecSwift: public virtual HybridHaptikaPPGFrameProcessorSpec {
public:
// Constructor from a Swift instance
explicit HybridHaptikaPPGFrameProcessorSpecSwift(const HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx& swiftPart):
HybridObject(HybridHaptikaPPGFrameProcessorSpec::TAG),
_swiftPart(swiftPart) { }
public:
// Get the Swift part
inline HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx& getSwiftPart() noexcept {
return _swiftPart;
}
public:
inline size_t getExternalMemorySize() noexcept override {
return _swiftPart.getMemorySize();
}
bool equals(const std::shared_ptr<HybridObject>& other) override {
if (auto otherCast = std::dynamic_pointer_cast<HybridHaptikaPPGFrameProcessorSpecSwift>(other)) {
return _swiftPart.equals(otherCast->_swiftPart);
}
return false;
}
void dispose() noexcept override {
_swiftPart.dispose();
}
std::string toString() override {
return _swiftPart.toString();
}
public:
// Properties
public:
// Methods
inline void startMeasurement(const NativePPGConfiguration& configuration) override {
auto __result = _swiftPart.startMeasurement(std::forward<decltype(configuration)>(configuration));
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
inline std::optional<PPGFrameUpdate> processFrame(const std::shared_ptr<margelo::nitro::camera::HybridFrameSpec>& frame) override {
auto __result = _swiftPart.processFrame(frame);
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline NativePPGProcessingResult finishMeasurement() override {
auto __result = _swiftPart.finishMeasurement();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
inline void cancelMeasurement() override {
auto __result = _swiftPart.cancelMeasurement();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
}
inline ThermalState getThermalState() override {
auto __result = _swiftPart.getThermalState();
if (__result.hasError()) [[unlikely]] {
std::rethrow_exception(__result.error());
}
auto __value = std::move(__result.value());
return __value;
}
private:
HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx _swiftPart;
};
} // namespace margelo::nitro::haptika::ppg
@@ -0,0 +1,56 @@
///
/// FingerContactState.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
/**
* Represents the JS union `FingerContactState`, backed by a C++ enum.
*/
public typealias FingerContactState = margelo.nitro.haptika.ppg.FingerContactState
public extension FingerContactState {
/**
* Get a FingerContactState for the given String value, or
* return `nil` if the given value was invalid/unknown.
*/
init?(fromString string: String) {
switch string {
case "absent":
self = .absent
case "adjusting":
self = .adjusting
case "valid":
self = .valid
case "tooMuchPressure":
self = .toomuchpressure
case "overexposed":
self = .overexposed
case "unstable":
self = .unstable
default:
return nil
}
}
/**
* Get the String value this FingerContactState represents.
*/
var stringValue: String {
switch self {
case .absent:
return "absent"
case .adjusting:
return "adjusting"
case .valid:
return "valid"
case .toomuchpressure:
return "tooMuchPressure"
case .overexposed:
return "overexposed"
case .unstable:
return "unstable"
}
}
}
@@ -0,0 +1,46 @@
///
/// Func_void_NativeHealthKitContext.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/**
* Wraps a Swift `(_ value: NativeHealthKitContext) -> Void` as a class.
* This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`.
*/
public final class Func_void_NativeHealthKitContext {
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
private let closure: (_ value: NativeHealthKitContext) -> Void
public init(_ closure: @escaping (_ value: NativeHealthKitContext) -> Void) {
self.closure = closure
}
@inline(__always)
public func call(value: NativeHealthKitContext) -> Void {
self.closure(value)
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
@inline(__always)
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `Func_void_NativeHealthKitContext`.
* The pointer has to be a retained opaque `Unmanaged<Func_void_NativeHealthKitContext>`.
* This removes one strong reference from the object!
*/
@inline(__always)
public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_NativeHealthKitContext {
return Unmanaged<Func_void_NativeHealthKitContext>.fromOpaque(pointer).takeRetainedValue()
}
}
@@ -0,0 +1,46 @@
///
/// Func_void_bool.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/**
* Wraps a Swift `(_ value: Bool) -> Void` as a class.
* This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`.
*/
public final class Func_void_bool {
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
private let closure: (_ value: Bool) -> Void
public init(_ closure: @escaping (_ value: Bool) -> Void) {
self.closure = closure
}
@inline(__always)
public func call(value: Bool) -> Void {
self.closure(value)
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
@inline(__always)
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `Func_void_bool`.
* The pointer has to be a retained opaque `Unmanaged<Func_void_bool>`.
* This removes one strong reference from the object!
*/
@inline(__always)
public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_bool {
return Unmanaged<Func_void_bool>.fromOpaque(pointer).takeRetainedValue()
}
}
@@ -0,0 +1,46 @@
///
/// Func_void_std__exception_ptr.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/**
* Wraps a Swift `(_ error: Error) -> Void` as a class.
* This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`.
*/
public final class Func_void_std__exception_ptr {
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
private let closure: (_ error: Error) -> Void
public init(_ closure: @escaping (_ error: Error) -> Void) {
self.closure = closure
}
@inline(__always)
public func call(error: std.exception_ptr) -> Void {
self.closure(RuntimeError.from(cppError: error))
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
@inline(__always)
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `Func_void_std__exception_ptr`.
* The pointer has to be a retained opaque `Unmanaged<Func_void_std__exception_ptr>`.
* This removes one strong reference from the object!
*/
@inline(__always)
public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__exception_ptr {
return Unmanaged<Func_void_std__exception_ptr>.fromOpaque(pointer).takeRetainedValue()
}
}
@@ -0,0 +1,46 @@
///
/// Func_void_std__optional_NativeHeartbeatSeriesSummary_.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/**
* Wraps a Swift `(_ value: NativeHeartbeatSeriesSummary?) -> Void` as a class.
* This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`.
*/
public final class Func_void_std__optional_NativeHeartbeatSeriesSummary_ {
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
private let closure: (_ value: NativeHeartbeatSeriesSummary?) -> Void
public init(_ closure: @escaping (_ value: NativeHeartbeatSeriesSummary?) -> Void) {
self.closure = closure
}
@inline(__always)
public func call(value: bridge.std__optional_NativeHeartbeatSeriesSummary_) -> Void {
self.closure(value.value)
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
@inline(__always)
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `Func_void_std__optional_NativeHeartbeatSeriesSummary_`.
* The pointer has to be a retained opaque `Unmanaged<Func_void_std__optional_NativeHeartbeatSeriesSummary_>`.
* This removes one strong reference from the object!
*/
@inline(__always)
public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__optional_NativeHeartbeatSeriesSummary_ {
return Unmanaged<Func_void_std__optional_NativeHeartbeatSeriesSummary_>.fromOpaque(pointer).takeRetainedValue()
}
}
@@ -0,0 +1,44 @@
///
/// HealthMetricType.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
/**
* Represents the JS union `HealthMetricType`, backed by a C++ enum.
*/
public typealias HealthMetricType = margelo.nitro.haptika.ppg.HealthMetricType
public extension HealthMetricType {
/**
* Get a HealthMetricType for the given String value, or
* return `nil` if the given value was invalid/unknown.
*/
init?(fromString string: String) {
switch string {
case "heartRate":
self = .heartrate
case "restingHeartRate":
self = .restingheartrate
case "hrvSdnn":
self = .hrvsdnn
default:
return nil
}
}
/**
* Get the String value this HealthMetricType represents.
*/
var stringValue: String {
switch self {
case .heartrate:
return "heartRate"
case .restingheartrate:
return "restingHeartRate"
case .hrvsdnn:
return "hrvSdnn"
}
}
}
@@ -0,0 +1,64 @@
///
/// HybridHaptikaCardiacMirrorSpec.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/// See ``HybridHaptikaCardiacMirrorSpec``
public protocol HybridHaptikaCardiacMirrorSpec_protocol: HybridObject {
// Properties
// Methods
func isSupported() throws -> Bool
func patternInfo() throws -> NativeCardiacMirrorPatternInfo
func start(targetBpm: Double) throws -> Void
func pulse() throws -> Void
func stop() throws -> Void
func startWatchSession() throws -> Void
func watchReachable() throws -> Bool
func latestHeartbeat() throws -> NativeCardiacMirrorHeartbeat?
func playRegulationProtocol(kind: String) throws -> Double
func stopRegulationProtocol() throws -> Void
}
public extension HybridHaptikaCardiacMirrorSpec_protocol {
/// Default implementation of ``HybridObject.toString``
func toString() -> String {
return "[HybridObject HaptikaCardiacMirror]"
}
}
/// See ``HybridHaptikaCardiacMirrorSpec``
open class HybridHaptikaCardiacMirrorSpec_base {
private weak var cxxWrapper: HybridHaptikaCardiacMirrorSpec_cxx? = nil
public init() { }
public func getCxxWrapper() -> HybridHaptikaCardiacMirrorSpec_cxx {
#if DEBUG
guard self is any HybridHaptikaCardiacMirrorSpec else {
fatalError("`self` is not a `HybridHaptikaCardiacMirrorSpec`! Did you accidentally inherit from `HybridHaptikaCardiacMirrorSpec_base` instead of `HybridHaptikaCardiacMirrorSpec`?")
}
#endif
if let cxxWrapper = self.cxxWrapper {
return cxxWrapper
} else {
let cxxWrapper = HybridHaptikaCardiacMirrorSpec_cxx(self as! any HybridHaptikaCardiacMirrorSpec)
self.cxxWrapper = cxxWrapper
return cxxWrapper
}
}
}
/**
* A Swift base-protocol representing the HaptikaCardiacMirror HybridObject.
* Implement this protocol to create Swift-based instances of HaptikaCardiacMirror.
* ```swift
* class HybridHaptikaCardiacMirror : HybridHaptikaCardiacMirrorSpec {
* // ...
* }
* ```
*/
public typealias HybridHaptikaCardiacMirrorSpec = HybridHaptikaCardiacMirrorSpec_protocol & HybridHaptikaCardiacMirrorSpec_base
@@ -0,0 +1,247 @@
///
/// HybridHaptikaCardiacMirrorSpec_cxx.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/**
* A class implementation that bridges HybridHaptikaCardiacMirrorSpec over to C++.
* In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined.
*
* Also, some Swift types need to be bridged with special handling:
* - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330)
* - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper
* - Throwing methods need to be wrapped with a Result<T, Error> type, as exceptions cannot be propagated to C++
*/
open class HybridHaptikaCardiacMirrorSpec_cxx {
/**
* The Swift <> C++ bridge's namespace (`margelo::nitro::haptika::ppg::bridge::swift`)
* from `HaptikaPPG-Swift-Cxx-Bridge.hpp`.
* This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift.
*/
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
/**
* Holds an instance of the `HybridHaptikaCardiacMirrorSpec` Swift protocol.
*/
private var __implementation: any HybridHaptikaCardiacMirrorSpec
/**
* Holds a weak pointer to the C++ class that wraps the Swift class.
*/
private var __cxxPart: bridge.std__weak_ptr_HybridHaptikaCardiacMirrorSpec_
/**
* Create a new `HybridHaptikaCardiacMirrorSpec_cxx` that wraps the given `HybridHaptikaCardiacMirrorSpec`.
* All properties and methods bridge to C++ types.
*/
public init(_ implementation: any HybridHaptikaCardiacMirrorSpec) {
self.__implementation = implementation
self.__cxxPart = .init()
/* no base class */
}
/**
* Get the actual `HybridHaptikaCardiacMirrorSpec` instance this class wraps.
*/
@inline(__always)
public func getHybridHaptikaCardiacMirrorSpec() -> any HybridHaptikaCardiacMirrorSpec {
return __implementation
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `HybridHaptikaCardiacMirrorSpec_cxx`.
* The pointer has to be a retained opaque `Unmanaged<HybridHaptikaCardiacMirrorSpec_cxx>`.
* This removes one strong reference from the object!
*/
public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridHaptikaCardiacMirrorSpec_cxx {
return Unmanaged<HybridHaptikaCardiacMirrorSpec_cxx>.fromOpaque(pointer).takeRetainedValue()
}
/**
* Gets (or creates) the C++ part of this Hybrid Object.
* The C++ part is a `std::shared_ptr<HybridHaptikaCardiacMirrorSpec>`.
*/
public func getCxxPart() -> bridge.std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ {
let cachedCxxPart = self.__cxxPart.lock()
if Bool(fromCxx: cachedCxxPart) {
return cachedCxxPart
} else {
let newCxxPart = bridge.create_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(self.toUnsafe())
__cxxPart = bridge.weakify_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(newCxxPart)
return newCxxPart
}
}
/**
* Get the memory size of the Swift class (plus size of any other allocations)
* so the JS VM can properly track it and garbage-collect the JS object if needed.
*/
@inline(__always)
public var memorySize: Int {
return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize
}
/**
* Compares this object with the given [other] object for reference equality.
*/
@inline(__always)
public func equals(other: HybridHaptikaCardiacMirrorSpec_cxx) -> Bool {
return self.__implementation === other.__implementation
}
/**
* Call dispose() on the Swift class.
* This _may_ be called manually from JS.
*/
@inline(__always)
public func dispose() {
self.__implementation.dispose()
}
/**
* Call toString() on the Swift class.
*/
@inline(__always)
public func toString() -> String {
return self.__implementation.toString()
}
// Properties
// Methods
@inline(__always)
public final func isSupported() -> bridge.Result_bool_ {
do {
let __result = try self.__implementation.isSupported()
let __resultCpp = __result
return bridge.create_Result_bool_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_bool_(__exceptionPtr)
}
}
@inline(__always)
public final func patternInfo() -> bridge.Result_NativeCardiacMirrorPatternInfo_ {
do {
let __result = try self.__implementation.patternInfo()
let __resultCpp = __result
return bridge.create_Result_NativeCardiacMirrorPatternInfo_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_NativeCardiacMirrorPatternInfo_(__exceptionPtr)
}
}
@inline(__always)
public final func start(targetBpm: Double) -> bridge.Result_void_ {
do {
try self.__implementation.start(targetBpm: targetBpm)
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
@inline(__always)
public final func pulse() -> bridge.Result_void_ {
do {
try self.__implementation.pulse()
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
@inline(__always)
public final func stop() -> bridge.Result_void_ {
do {
try self.__implementation.stop()
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
@inline(__always)
public final func startWatchSession() -> bridge.Result_void_ {
do {
try self.__implementation.startWatchSession()
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
@inline(__always)
public final func watchReachable() -> bridge.Result_bool_ {
do {
let __result = try self.__implementation.watchReachable()
let __resultCpp = __result
return bridge.create_Result_bool_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_bool_(__exceptionPtr)
}
}
@inline(__always)
public final func latestHeartbeat() -> bridge.Result_std__optional_NativeCardiacMirrorHeartbeat__ {
do {
let __result = try self.__implementation.latestHeartbeat()
let __resultCpp = { () -> bridge.std__optional_NativeCardiacMirrorHeartbeat_ in
if let __unwrappedValue = __result {
return bridge.create_std__optional_NativeCardiacMirrorHeartbeat_(__unwrappedValue)
} else {
return .init()
}
}()
return bridge.create_Result_std__optional_NativeCardiacMirrorHeartbeat__(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_std__optional_NativeCardiacMirrorHeartbeat__(__exceptionPtr)
}
}
@inline(__always)
public final func playRegulationProtocol(kind: std.string) -> bridge.Result_double_ {
do {
let __result = try self.__implementation.playRegulationProtocol(kind: String(kind))
let __resultCpp = __result
return bridge.create_Result_double_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_double_(__exceptionPtr)
}
}
@inline(__always)
public final func stopRegulationProtocol() -> bridge.Result_void_ {
do {
try self.__implementation.stopRegulationProtocol()
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
}
@@ -0,0 +1,58 @@
///
/// HybridHaptikaHealthKitSpec.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/// See ``HybridHaptikaHealthKitSpec``
public protocol HybridHaptikaHealthKitSpec_protocol: HybridObject {
// Properties
// Methods
func isAvailable() throws -> Bool
func requestReadAuthorization() throws -> Promise<Bool>
func fetchContext(lookbackDays: Double) throws -> Promise<NativeHealthKitContext>
func fetchLatestHeartbeatSeries() throws -> Promise<NativeHeartbeatSeriesSummary?>
}
public extension HybridHaptikaHealthKitSpec_protocol {
/// Default implementation of ``HybridObject.toString``
func toString() -> String {
return "[HybridObject HaptikaHealthKit]"
}
}
/// See ``HybridHaptikaHealthKitSpec``
open class HybridHaptikaHealthKitSpec_base {
private weak var cxxWrapper: HybridHaptikaHealthKitSpec_cxx? = nil
public init() { }
public func getCxxWrapper() -> HybridHaptikaHealthKitSpec_cxx {
#if DEBUG
guard self is any HybridHaptikaHealthKitSpec else {
fatalError("`self` is not a `HybridHaptikaHealthKitSpec`! Did you accidentally inherit from `HybridHaptikaHealthKitSpec_base` instead of `HybridHaptikaHealthKitSpec`?")
}
#endif
if let cxxWrapper = self.cxxWrapper {
return cxxWrapper
} else {
let cxxWrapper = HybridHaptikaHealthKitSpec_cxx(self as! any HybridHaptikaHealthKitSpec)
self.cxxWrapper = cxxWrapper
return cxxWrapper
}
}
}
/**
* A Swift base-protocol representing the HaptikaHealthKit HybridObject.
* Implement this protocol to create Swift-based instances of HaptikaHealthKit.
* ```swift
* class HybridHaptikaHealthKit : HybridHaptikaHealthKitSpec {
* // ...
* }
* ```
*/
public typealias HybridHaptikaHealthKitSpec = HybridHaptikaHealthKitSpec_protocol & HybridHaptikaHealthKitSpec_base
@@ -0,0 +1,201 @@
///
/// HybridHaptikaHealthKitSpec_cxx.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
/**
* A class implementation that bridges HybridHaptikaHealthKitSpec over to C++.
* In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined.
*
* Also, some Swift types need to be bridged with special handling:
* - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330)
* - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper
* - Throwing methods need to be wrapped with a Result<T, Error> type, as exceptions cannot be propagated to C++
*/
open class HybridHaptikaHealthKitSpec_cxx {
/**
* The Swift <> C++ bridge's namespace (`margelo::nitro::haptika::ppg::bridge::swift`)
* from `HaptikaPPG-Swift-Cxx-Bridge.hpp`.
* This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift.
*/
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
/**
* Holds an instance of the `HybridHaptikaHealthKitSpec` Swift protocol.
*/
private var __implementation: any HybridHaptikaHealthKitSpec
/**
* Holds a weak pointer to the C++ class that wraps the Swift class.
*/
private var __cxxPart: bridge.std__weak_ptr_HybridHaptikaHealthKitSpec_
/**
* Create a new `HybridHaptikaHealthKitSpec_cxx` that wraps the given `HybridHaptikaHealthKitSpec`.
* All properties and methods bridge to C++ types.
*/
public init(_ implementation: any HybridHaptikaHealthKitSpec) {
self.__implementation = implementation
self.__cxxPart = .init()
/* no base class */
}
/**
* Get the actual `HybridHaptikaHealthKitSpec` instance this class wraps.
*/
@inline(__always)
public func getHybridHaptikaHealthKitSpec() -> any HybridHaptikaHealthKitSpec {
return __implementation
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `HybridHaptikaHealthKitSpec_cxx`.
* The pointer has to be a retained opaque `Unmanaged<HybridHaptikaHealthKitSpec_cxx>`.
* This removes one strong reference from the object!
*/
public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridHaptikaHealthKitSpec_cxx {
return Unmanaged<HybridHaptikaHealthKitSpec_cxx>.fromOpaque(pointer).takeRetainedValue()
}
/**
* Gets (or creates) the C++ part of this Hybrid Object.
* The C++ part is a `std::shared_ptr<HybridHaptikaHealthKitSpec>`.
*/
public func getCxxPart() -> bridge.std__shared_ptr_HybridHaptikaHealthKitSpec_ {
let cachedCxxPart = self.__cxxPart.lock()
if Bool(fromCxx: cachedCxxPart) {
return cachedCxxPart
} else {
let newCxxPart = bridge.create_std__shared_ptr_HybridHaptikaHealthKitSpec_(self.toUnsafe())
__cxxPart = bridge.weakify_std__shared_ptr_HybridHaptikaHealthKitSpec_(newCxxPart)
return newCxxPart
}
}
/**
* Get the memory size of the Swift class (plus size of any other allocations)
* so the JS VM can properly track it and garbage-collect the JS object if needed.
*/
@inline(__always)
public var memorySize: Int {
return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize
}
/**
* Compares this object with the given [other] object for reference equality.
*/
@inline(__always)
public func equals(other: HybridHaptikaHealthKitSpec_cxx) -> Bool {
return self.__implementation === other.__implementation
}
/**
* Call dispose() on the Swift class.
* This _may_ be called manually from JS.
*/
@inline(__always)
public func dispose() {
self.__implementation.dispose()
}
/**
* Call toString() on the Swift class.
*/
@inline(__always)
public func toString() -> String {
return self.__implementation.toString()
}
// Properties
// Methods
@inline(__always)
public final func isAvailable() -> bridge.Result_bool_ {
do {
let __result = try self.__implementation.isAvailable()
let __resultCpp = __result
return bridge.create_Result_bool_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_bool_(__exceptionPtr)
}
}
@inline(__always)
public final func requestReadAuthorization() -> bridge.Result_std__shared_ptr_Promise_bool___ {
do {
let __result = try self.__implementation.requestReadAuthorization()
let __resultCpp = { () -> bridge.std__shared_ptr_Promise_bool__ in
let __promise = bridge.create_std__shared_ptr_Promise_bool__()
let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_bool__(__promise)
__result
.then({ __result in __promiseHolder.resolve(__result) })
.catch({ __error in __promiseHolder.reject(__error.toCpp()) })
return __promise
}()
return bridge.create_Result_std__shared_ptr_Promise_bool___(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_std__shared_ptr_Promise_bool___(__exceptionPtr)
}
}
@inline(__always)
public final func fetchContext(lookbackDays: Double) -> bridge.Result_std__shared_ptr_Promise_NativeHealthKitContext___ {
do {
let __result = try self.__implementation.fetchContext(lookbackDays: lookbackDays)
let __resultCpp = { () -> bridge.std__shared_ptr_Promise_NativeHealthKitContext__ in
let __promise = bridge.create_std__shared_ptr_Promise_NativeHealthKitContext__()
let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_NativeHealthKitContext__(__promise)
__result
.then({ __result in __promiseHolder.resolve(__result) })
.catch({ __error in __promiseHolder.reject(__error.toCpp()) })
return __promise
}()
return bridge.create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(__exceptionPtr)
}
}
@inline(__always)
public final func fetchLatestHeartbeatSeries() -> bridge.Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ {
do {
let __result = try self.__implementation.fetchLatestHeartbeatSeries()
let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___ in
let __promise = bridge.create_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___()
let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___(__promise)
__result
.then({ __result in __promiseHolder.resolve({ () -> bridge.std__optional_NativeHeartbeatSeriesSummary_ in
if let __unwrappedValue = __result {
return bridge.create_std__optional_NativeHeartbeatSeriesSummary_(__unwrappedValue)
} else {
return .init()
}
}()) })
.catch({ __error in __promiseHolder.reject(__error.toCpp()) })
return __promise
}()
return bridge.create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(__exceptionPtr)
}
}
}
@@ -0,0 +1,60 @@
///
/// HybridHaptikaPPGFrameProcessorSpec.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import VisionCamera
import NitroModules
/// See ``HybridHaptikaPPGFrameProcessorSpec``
public protocol HybridHaptikaPPGFrameProcessorSpec_protocol: HybridObject {
// Properties
// Methods
func startMeasurement(configuration: NativePPGConfiguration) throws -> Void
func processFrame(frame: (any HybridFrameSpec)) throws -> PPGFrameUpdate?
func finishMeasurement() throws -> NativePPGProcessingResult
func cancelMeasurement() throws -> Void
func getThermalState() throws -> ThermalState
}
public extension HybridHaptikaPPGFrameProcessorSpec_protocol {
/// Default implementation of ``HybridObject.toString``
func toString() -> String {
return "[HybridObject HaptikaPPGFrameProcessor]"
}
}
/// See ``HybridHaptikaPPGFrameProcessorSpec``
open class HybridHaptikaPPGFrameProcessorSpec_base {
private weak var cxxWrapper: HybridHaptikaPPGFrameProcessorSpec_cxx? = nil
public init() { }
public func getCxxWrapper() -> HybridHaptikaPPGFrameProcessorSpec_cxx {
#if DEBUG
guard self is any HybridHaptikaPPGFrameProcessorSpec else {
fatalError("`self` is not a `HybridHaptikaPPGFrameProcessorSpec`! Did you accidentally inherit from `HybridHaptikaPPGFrameProcessorSpec_base` instead of `HybridHaptikaPPGFrameProcessorSpec`?")
}
#endif
if let cxxWrapper = self.cxxWrapper {
return cxxWrapper
} else {
let cxxWrapper = HybridHaptikaPPGFrameProcessorSpec_cxx(self as! any HybridHaptikaPPGFrameProcessorSpec)
self.cxxWrapper = cxxWrapper
return cxxWrapper
}
}
}
/**
* A Swift base-protocol representing the HaptikaPPGFrameProcessor HybridObject.
* Implement this protocol to create Swift-based instances of HaptikaPPGFrameProcessor.
* ```swift
* class HybridHaptikaPPGFrameProcessor : HybridHaptikaPPGFrameProcessorSpec {
* // ...
* }
* ```
*/
public typealias HybridHaptikaPPGFrameProcessorSpec = HybridHaptikaPPGFrameProcessorSpec_protocol & HybridHaptikaPPGFrameProcessorSpec_base
@@ -0,0 +1,195 @@
///
/// HybridHaptikaPPGFrameProcessorSpec_cxx.swift
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
/// https://github.com/mrousavy/nitro
/// Copyright © Marc Rousavy @ Margelo
///
import NitroModules
import VisionCamera
/**
* A class implementation that bridges HybridHaptikaPPGFrameProcessorSpec over to C++.
* In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined.
*
* Also, some Swift types need to be bridged with special handling:
* - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330)
* - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper
* - Throwing methods need to be wrapped with a Result<T, Error> type, as exceptions cannot be propagated to C++
*/
open class HybridHaptikaPPGFrameProcessorSpec_cxx {
/**
* The Swift <> C++ bridge's namespace (`margelo::nitro::haptika::ppg::bridge::swift`)
* from `HaptikaPPG-Swift-Cxx-Bridge.hpp`.
* This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift.
*/
public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift
/**
* Holds an instance of the `HybridHaptikaPPGFrameProcessorSpec` Swift protocol.
*/
private var __implementation: any HybridHaptikaPPGFrameProcessorSpec
/**
* Holds a weak pointer to the C++ class that wraps the Swift class.
*/
private var __cxxPart: bridge.std__weak_ptr_HybridHaptikaPPGFrameProcessorSpec_
/**
* Create a new `HybridHaptikaPPGFrameProcessorSpec_cxx` that wraps the given `HybridHaptikaPPGFrameProcessorSpec`.
* All properties and methods bridge to C++ types.
*/
public init(_ implementation: any HybridHaptikaPPGFrameProcessorSpec) {
self.__implementation = implementation
self.__cxxPart = .init()
/* no base class */
}
/**
* Get the actual `HybridHaptikaPPGFrameProcessorSpec` instance this class wraps.
*/
@inline(__always)
public func getHybridHaptikaPPGFrameProcessorSpec() -> any HybridHaptikaPPGFrameProcessorSpec {
return __implementation
}
/**
* Casts this instance to a retained unsafe raw pointer.
* This acquires one additional strong reference on the object!
*/
public func toUnsafe() -> UnsafeMutableRawPointer {
return Unmanaged.passRetained(self).toOpaque()
}
/**
* Casts an unsafe pointer to a `HybridHaptikaPPGFrameProcessorSpec_cxx`.
* The pointer has to be a retained opaque `Unmanaged<HybridHaptikaPPGFrameProcessorSpec_cxx>`.
* This removes one strong reference from the object!
*/
public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridHaptikaPPGFrameProcessorSpec_cxx {
return Unmanaged<HybridHaptikaPPGFrameProcessorSpec_cxx>.fromOpaque(pointer).takeRetainedValue()
}
/**
* Gets (or creates) the C++ part of this Hybrid Object.
* The C++ part is a `std::shared_ptr<HybridHaptikaPPGFrameProcessorSpec>`.
*/
public func getCxxPart() -> bridge.std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ {
let cachedCxxPart = self.__cxxPart.lock()
if Bool(fromCxx: cachedCxxPart) {
return cachedCxxPart
} else {
let newCxxPart = bridge.create_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(self.toUnsafe())
__cxxPart = bridge.weakify_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(newCxxPart)
return newCxxPart
}
}
/**
* Get the memory size of the Swift class (plus size of any other allocations)
* so the JS VM can properly track it and garbage-collect the JS object if needed.
*/
@inline(__always)
public var memorySize: Int {
return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize
}
/**
* Compares this object with the given [other] object for reference equality.
*/
@inline(__always)
public func equals(other: HybridHaptikaPPGFrameProcessorSpec_cxx) -> Bool {
return self.__implementation === other.__implementation
}
/**
* Call dispose() on the Swift class.
* This _may_ be called manually from JS.
*/
@inline(__always)
public func dispose() {
self.__implementation.dispose()
}
/**
* Call toString() on the Swift class.
*/
@inline(__always)
public func toString() -> String {
return self.__implementation.toString()
}
// Properties
// Methods
@inline(__always)
public final func startMeasurement(configuration: NativePPGConfiguration) -> bridge.Result_void_ {
do {
try self.__implementation.startMeasurement(configuration: configuration)
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
@inline(__always)
public final func processFrame(frame: bridge.std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_) -> bridge.Result_std__optional_PPGFrameUpdate__ {
do {
let __result = try self.__implementation.processFrame(frame: { () -> any HybridFrameSpec in
let __unsafePointer = bridge.get_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(frame)
let __instance = HybridFrameSpec_cxx.fromUnsafe(__unsafePointer)
return __instance.getHybridFrameSpec()
}())
let __resultCpp = { () -> bridge.std__optional_PPGFrameUpdate_ in
if let __unwrappedValue = __result {
return bridge.create_std__optional_PPGFrameUpdate_(__unwrappedValue)
} else {
return .init()
}
}()
return bridge.create_Result_std__optional_PPGFrameUpdate__(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_std__optional_PPGFrameUpdate__(__exceptionPtr)
}
}
@inline(__always)
public final func finishMeasurement() -> bridge.Result_NativePPGProcessingResult_ {
do {
let __result = try self.__implementation.finishMeasurement()
let __resultCpp = __result
return bridge.create_Result_NativePPGProcessingResult_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_NativePPGProcessingResult_(__exceptionPtr)
}
}
@inline(__always)
public final func cancelMeasurement() -> bridge.Result_void_ {
do {
try self.__implementation.cancelMeasurement()
return bridge.create_Result_void_()
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_void_(__exceptionPtr)
}
}
@inline(__always)
public final func getThermalState() -> bridge.Result_ThermalState_ {
do {
let __result = try self.__implementation.getThermalState()
let __resultCpp = __result
return bridge.create_Result_ThermalState_(__resultCpp)
} catch (let __error) {
let __exceptionPtr = __error.toCpp()
return bridge.create_Result_ThermalState_(__exceptionPtr)
}
}
}

Some files were not shown because too many files have changed in this diff Show More