diff --git a/FES-app/FES/app.json b/FES-app/FES/app.json index b862b56..446b3cc 100644 --- a/FES-app/FES/app.json +++ b/FES-app/FES/app.json @@ -9,7 +9,10 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "NSMotionUsageDescription": "This app uses motion sensors to detect foot movement for FES device control." + } }, "android": { "adaptiveIcon": { @@ -19,7 +22,11 @@ "monochromeImage": "./assets/images/android-icon-monochrome.png" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "permissions": [ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION" + ] }, "web": { "output": "static", diff --git a/FES-app/FES/app/functional/calibration.tsx b/FES-app/FES/app/functional/calibration.tsx index 52d1168..bc6524d 100644 --- a/FES-app/FES/app/functional/calibration.tsx +++ b/FES-app/FES/app/functional/calibration.tsx @@ -1,41 +1,74 @@ -import { StyleSheet, View, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native'; +import { StyleSheet, View, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useState, useEffect } from 'react'; +import { useColorScheme } from 'react-native'; +import { calibrationService, CalibrationProgress, CalibrationResult } from '../../services/backend/calibration'; export default function CalibrationScreen() { const router = useRouter(); + const colorScheme = useColorScheme(); const [currentStep, setCurrentStep] = useState(1); const [isCalibrating, setIsCalibrating] = useState(false); const [progress, setProgress] = useState(0); + const [calibrationProgress, setCalibrationProgress] = useState({ + currentStep: 0, + totalSteps: 5, + isValidating: false, + validationMessage: '' + }); + const [calibrationResults, setCalibrationResults] = useState([]); + const [calibrationComplete, setCalibrationComplete] = useState(false); - useEffect(() => { - let interval: ReturnType; - - if (isCalibrating) { - interval = setInterval(() => { - setProgress(prev => { - if (prev >= 100) { - clearInterval(interval); - setCurrentStep(2); - setIsCalibrating(false); - return 100; - } - return prev + 10; - }); - }, 300); - } - - return () => clearInterval(interval); - }, [isCalibrating]); + // Dynamic colors based on color scheme + const colors = { + instructionCard: colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7', + warningCard: colorScheme === 'dark' ? '#1C3A5C' : '#E3F2FD', + resultsContainer: colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7', + finalResultsContainer: colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7', + progressBar: colorScheme === 'dark' ? '#3A3A3C' : '#E0E0E0', + borderColor: colorScheme === 'dark' ? '#3A3A3C' : '#E5E5EA', + headerBorder: colorScheme === 'dark' ? '#3A3A3C' : '#E5E5EA', + }; - const handleStartCalibration = () => { - setProgress(0); - setIsCalibrating(true); + const handleStartCalibration = async () => { + try { + setIsCalibrating(true); + setCalibrationComplete(false); + setCalibrationResults([]); + + await calibrationService.startCalibration( + (progress) => { + setCalibrationProgress(progress); + setProgress((progress.currentStep / progress.totalSteps) * 100); + }, + (results) => { + setCalibrationResults(results); + setCalibrationComplete(true); + setIsCalibrating(false); + setCurrentStep(2); + + Alert.alert( + 'Calibration Complete', + `Calibration successful! All 5 foot angles captured.`, + [{ text: 'OK' }] + ); + } + ); + } catch (error) { + console.error('Calibration failed:', error); + Alert.alert( + 'Calibration Failed', + 'An error occurred during calibration. Please try again.', + [{ text: 'OK' }] + ); + setIsCalibrating(false); + } }; + const handleComplete = () => { // Navigate back to home after calibration router.back(); @@ -43,7 +76,7 @@ export default function CalibrationScreen() { return ( - + router.back()} style={styles.backButton}> @@ -64,15 +97,22 @@ export default function CalibrationScreen() { - Prepare for Calibration + Foot Drop Calibration - Please make sure the device is properly attached and you're in a comfortable position. + We'll capture the angle when your foot gets "stuck" during walking to detect future foot drop episodes. - - + + + + Walk normally and let your foot get stuck at its natural "drop" position. We'll capture that angle 5 times for 5 seconds each. + + + + + - Ensure the electrodes are properly placed on your skin and the device is turned on. + The phone should be attached to your leg/foot to measure the angle when foot drop occurs. @@ -80,11 +120,38 @@ export default function CalibrationScreen() { - Calibrating... {progress}% + {calibrationProgress.validationMessage} - + {calibrationProgress.currentAngle !== undefined && ( + + Foot Angle: {calibrationProgress.currentAngle.toFixed(2)}° + + )} + + Capturing foot angle {calibrationProgress.currentStep} of {calibrationProgress.totalSteps} ({progress.toFixed(0)}%) + + + + {/* Show results as they come in */} + {calibrationResults.length > 0 && ( + + Foot Angles Captured: + {calibrationResults.map((result, index) => ( + + + Capture {index + 1}: {result.angle.toFixed(2)}° + + + ))} + {calibrationResults.length > 0 && ( + + Target Foot Drop Angle: {calibrationService.getAverageCalibrationAngle().toFixed(2)}° + + )} + + )} ) : ( Calibration Successful! + {calibrationResults.length > 0 && ( + + Final Calibration Results + + Average Foot Drop Angle: {calibrationService.getAverageCalibrationAngle().toFixed(2)}° + + + {calibrationResults.map((result, index) => ( + + + Capture {index + 1}: {result.angle.toFixed(2)}° + + + ))} + + + )} + router.push('/RealTimeData' as any)} + onPress={() => router.push('/functional/calibration' as any)} color="#FF9500" /> diff --git a/FES-app/FES/screens/RealTimeData.tsx b/FES-app/FES/screens/RealTimeData.tsx index 375d493..18fec9f 100644 --- a/FES-app/FES/screens/RealTimeData.tsx +++ b/FES-app/FES/screens/RealTimeData.tsx @@ -1,83 +1,275 @@ import { ThemedText } from '@/components/themed-text'; import React from 'react'; -import { StyleSheet, View, Text, ScrollView } from 'react-native'; -import { Gyroscope, DeviceMotion } from 'expo-sensors'; -import { useEffect, useState } from 'react'; +import { StyleSheet, View, ScrollView, Alert } from 'react-native'; +import { Gyroscope, DeviceMotion, Accelerometer } from 'expo-sensors'; +import { useEffect, useState, useRef } from 'react'; import { TouchableOpacity } from 'react-native'; import { ThemedView } from '@/components/themed-view'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { calibrationService } from '../services/backend/calibration'; + +// Safe number formatter +const formatNumber = (value: number | undefined | null): string => { + if (value === undefined || value === null || isNaN(value)) { + return '0.000'; + } + return value.toFixed(3); +}; + +// Safe object property getter +const getSafeValue = (obj: any, prop: string): number => { + if (!obj || typeof obj !== 'object' || obj[prop] === undefined || obj[prop] === null) { + return 0; + } + return typeof obj[prop] === 'number' ? obj[prop] : 0; +}; + export default function RealTimeData() { - const router = useRouter(); - const insets = useSafeAreaInsets(); - const [data, setData] = useState({ x: 0, y: 0, z: 0 }); - const [motionData, setMotionData] = useState({}); + const router = useRouter(); + const insets = useSafeAreaInsets(); + + // Raw sensor data state + const [gyroData, setGyroData] = useState({ x: 0, y: 0, z: 0 }); + const [accelData, setAccelData] = useState({ x: 0, y: 0, z: 0 }); + const [angleData, setAngleData] = useState({ x: 0, y: 0, z: 0 }); + + // Data smoothing refs + const gyroHistory = useRef>([]); + const accelHistory = useRef>([]); + const angleHistory = useRef>([]); + + // Data smoothing function + const smoothSensorData = (newData: {x: number, y: number, z: number}, history: React.MutableRefObject>, maxHistory: number = 5) => { + // Add new data to history + history.current.push(newData); + + // Keep only recent history + if (history.current.length > maxHistory) { + history.current.shift(); + } + + // Calculate average + const avg = history.current.reduce((acc, curr) => ({ + x: acc.x + curr.x, + y: acc.y + curr.y, + z: acc.z + curr.z + }), { x: 0, y: 0, z: 0 }); + + return { + x: avg.x / history.current.length, + y: avg.y / history.current.length, + z: avg.z / history.current.length + }; + }; + + // Convert accelerometer data to angles (degrees) + const convertAccelToAngles = (accelData: {x: number, y: number, z: number}) => { + // Calculate angles from accelerometer data + const toDegrees = (radians: number) => radians * (180 / Math.PI); + + // Calculate pitch (X-axis rotation) - forward/back tilt + const pitch = Math.atan2(accelData.y, accelData.z); + + // Calculate roll (Y-axis rotation) - left/right tilt + const roll = Math.atan2(-accelData.x, Math.sqrt(accelData.y * accelData.y + accelData.z * accelData.z)); + + // Calculate yaw (Z-axis rotation) - rotation around vertical axis + const yaw = Math.atan2(accelData.x, accelData.y); + + return { + x: toDegrees(pitch), + y: toDegrees(roll), + z: toDegrees(yaw) + }; + }; + // Gyroscope listener (for rotation rate only) useEffect(() => { - const subscription = Gyroscope.addListener(setData); - Gyroscope.setUpdateInterval(100); // update every 100ms - return () => subscription.remove(); + let gyroSubscription: any = null; + + const startGyroscope = async () => { + try { + console.log('Starting gyroscope...'); + const isAvailable = await Gyroscope.isAvailableAsync(); + console.log('Gyroscope available:', isAvailable); + + if (isAvailable) { + Gyroscope.setUpdateInterval(50); // Very fast updates + gyroSubscription = Gyroscope.addListener((data) => { + if (data && typeof data === 'object') { + const rawData = { + x: getSafeValue(data, 'x'), + y: getSafeValue(data, 'y'), + z: getSafeValue(data, 'z') + }; + + // Apply smoothing + const smoothedData = smoothSensorData(rawData, gyroHistory, 3); + setGyroData(smoothedData); + } + }); + console.log('Gyroscope listener added'); + } else { + Alert.alert('Gyroscope Not Available', 'This device does not have a gyroscope or it is not accessible.'); + } + } catch (error) { + console.error('Gyroscope error:', error); + Alert.alert('Gyroscope Error', `Failed to access gyroscope: ${error}`); + } + }; + + startGyroscope(); + + return () => { + if (gyroSubscription) { + gyroSubscription.remove(); + console.log('Gyroscope listener removed'); + } + }; }, []); + + // Accelerometer listener useEffect(() => { - // Subscribe to device motion updates - const subscription = DeviceMotion.addListener((data) => { - setMotionData(data); - }); - DeviceMotion.setUpdateInterval(100); // every 100ms + let accelSubscription: any = null; + + const startAccelerometer = async () => { + try { + console.log('Starting accelerometer...'); + const isAvailable = await Accelerometer.isAvailableAsync(); + console.log('Accelerometer available:', isAvailable); + + if (isAvailable) { + Accelerometer.setUpdateInterval(50); // Very fast updates + accelSubscription = Accelerometer.addListener((data) => { + if (data && typeof data === 'object') { + const rawData = { + x: getSafeValue(data, 'x'), + y: getSafeValue(data, 'y'), + z: getSafeValue(data, 'z') + }; + + // Apply smoothing + const smoothedData = smoothSensorData(rawData, accelHistory, 3); + setAccelData(smoothedData); + + // Convert accelerometer to angles + const angleData = convertAccelToAngles(smoothedData); + const smoothedAngles = smoothSensorData(angleData, angleHistory, 3); + setAngleData(smoothedAngles); + } + }); + console.log('Accelerometer listener added'); + } else { + Alert.alert('Accelerometer Not Available', 'This device does not have an accelerometer or it is not accessible.'); + } + } catch (error) { + console.error('Accelerometer error:', error); + Alert.alert('Accelerometer Error', `Failed to access accelerometer: ${error}`); + } + }; + + startAccelerometer(); - return () => subscription.remove(); + return () => { + if (accelSubscription) { + accelSubscription.remove(); + console.log('Accelerometer listener removed'); + } + }; }, []); - const { acceleration, accelerationIncludingGravity, rotation, rotationRate, orientation } = motionData; - return ( - - - Gyroscope Data - - X: {data.x.toFixed(2)} - Y: {data.y.toFixed(2)} - Z: {data.z.toFixed(2)} - - - Device Motion Data - - Orientation: {orientation || 'N/A'} - - - Acceleration (no gravity):{" "} - {acceleration ? `${acceleration.x.toFixed(2)}, ${acceleration.y.toFixed(2)}, ${acceleration.z.toFixed(2)}` : "N/A"} - - - - Acceleration (with gravity):{" "} - {accelerationIncludingGravity ? `${accelerationIncludingGravity.x.toFixed(2)}, ${accelerationIncludingGravity.y.toFixed(2)}, ${accelerationIncludingGravity.z.toFixed(2)}` : "N/A"} - - - - Rotation (quaternion):{" "} - {rotation ? `${rotation.alpha.toFixed(2)}, ${rotation.beta.toFixed(2)}, ${rotation.gamma.toFixed(2)}` : "N/A"} - - - - Rotation rate:{" "} - {rotationRate ? `${rotationRate.alpha.toFixed(2)}, ${rotationRate.beta.toFixed(2)}, ${rotationRate.gamma.toFixed(2)}` : "N/A"} - - - - - - { - router.replace('/functional/home'); - }} - > - Back to Home - + + // Update calibration service with current y angle + useEffect(() => { + calibrationService.updateAngleData(angleData.y); + }, [angleData.y]); + + // Calculate magnitudes + const gyroMagnitude = Math.sqrt(gyroData.x * gyroData.x + gyroData.y * gyroData.y + gyroData.z * gyroData.z); + const accelMagnitude = Math.sqrt(accelData.x * accelData.x + accelData.y * accelData.y + accelData.z * accelData.z); + + return ( + + + Phone Angle (Degrees) - From Accelerometer + + + X-Axis (Forward/Back Tilt): {formatNumber(angleData.x)}° + 10 ? '#FF3B30' : '#34C759' }]} /> - - + + Y-Axis (Left/Right Tilt): {formatNumber(angleData.y)}° + 10 ? '#FF3B30' : '#34C759' }]} /> + + + Z-Axis (Rotation): {formatNumber(angleData.z)}° + 10 ? '#FF3B30' : '#34C759' }]} /> + + + + Total Tilt: {formatNumber(Math.sqrt(angleData.x * angleData.x + angleData.y * angleData.y))}° + + + + + Accelerometer Data (Movement) + + + X (Forward/Back): {formatNumber(accelData.x)} + 0.5 ? '#FF3B30' : '#34C759' }]} /> + + + Y (Up/Down): {formatNumber(accelData.y)} + 0.5 ? '#FF3B30' : '#34C759' }]} /> + + + Z (Side/Side): {formatNumber(accelData.z)} + 0.5 ? '#FF3B30' : '#34C759' }]} /> + + + + Movement Magnitude: {formatNumber(accelMagnitude)} + + + + + Instructions + + + • Tilt phone FORWARD/BACK - X-axis should change + + + • Tilt phone LEFT/RIGHT - Y-axis should change + + + • Rotate phone clockwise/counterclockwise - Z-axis should change + + + • 0° = phone perfectly vertical + + + • 45° = phone tilted 45 degrees + + + • 90° = phone horizontal + + + + + + { + router.replace('/functional/home'); + }} + > + Back to Home + + + ); } + const styles = StyleSheet.create({ container: { flex: 1, @@ -93,15 +285,44 @@ const styles = StyleSheet.create({ marginTop: 20, }, dataCard: { - backgroundColor: '#000000', + backgroundColor: '#1C1C1E', borderRadius: 12, padding: 16, marginBottom: 20, + borderWidth: 1, + borderColor: '#2C2C2E', }, dataLabel: { fontSize: 16, marginBottom: 8, lineHeight: 22, + color: '#FFFFFF', + fontWeight: '500', + flex: 1, + }, + dataRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + indicator: { + width: 12, + height: 12, + borderRadius: 6, + marginLeft: 10, + }, + magnitudeContainer: { + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#2C2C2E', + }, + magnitudeLabel: { + fontSize: 16, + color: '#FFFFFF', + fontWeight: '600', + textAlign: 'center', }, footer: { position: 'absolute', @@ -124,4 +345,4 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, -}); +}); \ No newline at end of file diff --git a/FES-app/FES/services/backend/calibration.tsx b/FES-app/FES/services/backend/calibration.tsx new file mode 100644 index 0000000..63a56f1 --- /dev/null +++ b/FES-app/FES/services/backend/calibration.tsx @@ -0,0 +1,281 @@ +import { Accelerometer } from 'expo-sensors'; +import * as Haptics from 'expo-haptics'; +import { Vibration } from 'react-native'; + +export interface CalibrationResult { + angle: number; + timestamp: number; + isValid: boolean; +} + +export interface CalibrationProgress { + currentStep: number; + totalSteps: number; + currentAngle?: number; + isValidating: boolean; + validationMessage: string; +} + +export class CalibrationService { + private static instance: CalibrationService; + private currentAngle: number = 0; + private isMonitoring = false; + private calibrationResults: CalibrationResult[] = []; + private progressCallback?: (progress: CalibrationProgress) => void; + private completionCallback?: (results: CalibrationResult[]) => void; + + private constructor() {} + + static getInstance(): CalibrationService { + if (!CalibrationService.instance) { + CalibrationService.instance = new CalibrationService(); + } + return CalibrationService.instance; + } + + // Method to update the current y angle from RealTimeData component + updateAngleData(yAngle: number) { + this.currentAngle = yAngle; + } + + // Start the calibration process + async startCalibration( + onProgress?: (progress: CalibrationProgress) => void, + onComplete?: (results: CalibrationResult[]) => void + ): Promise { + this.progressCallback = onProgress; + this.completionCallback = onComplete; + this.calibrationResults = []; + this.isMonitoring = true; + + try { + // Start accelerometer monitoring + const isAvailable = await Accelerometer.isAvailableAsync(); + if (!isAvailable) { + throw new Error('Accelerometer not available'); + } + + Accelerometer.setUpdateInterval(50); // Fast updates for real-time monitoring + + // Start accelerometer listener to get real-time data + let hasReceivedData = false; + let dataCount = 0; + const accelSubscription = Accelerometer.addListener((data) => { + if (data && typeof data === 'object' && data.x !== undefined && data.y !== undefined && data.z !== undefined) { + // Convert accelerometer data to angles (same as RealTimeData) + const angleData = this.convertAccelToAngles({ + x: data.x, + y: data.y, + z: data.z + }); + + // Update the current angle + this.currentAngle = angleData.y; + hasReceivedData = true; + dataCount++; + } + }); + + // Wait for initial accelerometer data to arrive + let waitCount = 0; + while (!hasReceivedData && waitCount < 20) { + await this.delay(50); + waitCount++; + } + + if (!hasReceivedData) { + // No accelerometer data received - continue anyway + } + + // Initial vibration: Signal to get ready for first step + await this.triggerHaptic(); + this.updateProgress({ + currentStep: 0, + totalSteps: 5, + isValidating: false, + validationMessage: 'Get ready! Calibration starting...' + }); + await this.delay(1000); // Brief pause before starting + + // Perform 5 calibration steps with haptic feedback + for (let step = 1; step <= 5; step++) { + if (!this.isMonitoring) break; // Allow cancellation + + // Vibration 1: Signal to start step + await this.triggerHaptic(); + this.updateProgress({ + currentStep: step, + totalSteps: 5, + isValidating: true, + validationMessage: `Step ${step}/5 - Start walking now! Capture in progress...` + }); + + // Capture angle for 5 seconds + const result = await this.performCalibrationStep(step); + this.calibrationResults.push(result); + + // Vibration 2: Signal step is done + await this.triggerHaptic(); + this.updateProgress({ + currentStep: step, + totalSteps: 5, + isValidating: false, + validationMessage: `Step ${step} complete! Captured: ${result.angle.toFixed(1)}°` + }); + + // Grace period: 3 seconds to return foot to normal position (only if not last step) + if (step < 5) { + await this.delay(3000); + + // Vibration 3: Signal to start next step + await this.triggerHaptic(); + this.updateProgress({ + currentStep: step, + totalSteps: 5, + isValidating: false, + validationMessage: `Get ready for step ${step + 1}/5...` + }); + } + } + + // Clean up accelerometer listener + accelSubscription.remove(); + + // Calibration complete + this.isMonitoring = false; + this.completionCallback?.(this.calibrationResults); + + } catch (error) { + console.error('Calibration error:', error); + this.isMonitoring = false; + throw error; + } + } + + private async performCalibrationStep(stepNumber: number): Promise { + const monitoringDuration = 5000; // 5 seconds + const threshold = -15; // degrees - only capture angles less than -15 degrees (foot drop) + const startTime = Date.now(); + + let angleSum = 0; + let sampleCount = 0; + let lastDisplayedAngle = 0; + + // Monitor for 5 seconds and collect ONLY angle readings less than -15 degrees (foot drop) + while (Date.now() - startTime < monitoringDuration) { + const currentAngle = this.currentAngle; + + // Always show current angle in UI (even if above threshold) + if (Math.abs(currentAngle - lastDisplayedAngle) > 0.5 || sampleCount === 0) { + const remainingTime = Math.max(0, Math.ceil((monitoringDuration - (Date.now() - startTime)) / 1000)); + this.updateProgress({ + currentStep: stepNumber, + totalSteps: 5, + currentAngle: currentAngle, + isValidating: true, + validationMessage: currentAngle < threshold + ? `Capturing foot drop... ${remainingTime}s remaining - Current: ${currentAngle.toFixed(1)}°` + : `Walking... ${remainingTime}s remaining - Current: ${currentAngle.toFixed(1)}° (waiting for foot drop)` + }); + lastDisplayedAngle = currentAngle; + } + + // Only include angles less than -15 degrees in the calculation (foot drop) + if (currentAngle < threshold) { + angleSum += currentAngle; + sampleCount++; + } + + await this.delay(50); // Check every 50ms + } + + // Calculate average only from angles below threshold + const averageAngle = sampleCount > 0 ? angleSum / sampleCount : 0; + + return { + angle: averageAngle, + timestamp: Date.now(), + isValid: sampleCount > 0 // Valid only if we captured some data below threshold + }; + } + + // Convert accelerometer data to angles (same as RealTimeData) + private convertAccelToAngles(accelData: {x: number, y: number, z: number}) { + const toDegrees = (radians: number) => radians * (180 / Math.PI); + + // Calculate pitch (X-axis rotation) - forward/back tilt + const pitch = Math.atan2(accelData.y, accelData.z); + + // Calculate roll (Y-axis rotation) - left/right tilt + const roll = Math.atan2(-accelData.x, Math.sqrt(accelData.y * accelData.y + accelData.z * accelData.z)); + + // Calculate yaw (Z-axis rotation) - rotation around vertical axis + const yaw = Math.atan2(accelData.x, accelData.y); + + return { + x: toDegrees(pitch), + y: toDegrees(roll), + z: toDegrees(yaw) + }; + } + + private updateProgress(progress: CalibrationProgress) { + this.progressCallback?.(progress); + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // Trigger haptic feedback with fallback to vibration - 1 second, one long buzz + private async triggerHaptic(): Promise { + try { + // Use heavy impact for more noticeable feedback + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + // One long continuous vibration for 1 second + Vibration.vibrate(1000); + await this.delay(1000); + } catch (error) { + // Fallback to vibration if haptics not available - 1 second continuous + Vibration.vibrate(1000); + await this.delay(1000); + } + } + + // Stop calibration process + stopCalibration(): void { + this.isMonitoring = false; + } + + // Get calibration results + getCalibrationResults(): CalibrationResult[] { + return [...this.calibrationResults]; + } + + // Get valid calibration results only + getValidCalibrationResults(): CalibrationResult[] { + return this.calibrationResults.filter(result => result.isValid); + } + + // Calculate average of all valid calibration angles + getAverageCalibrationAngle(): number { + const validResults = this.getValidCalibrationResults(); + if (validResults.length === 0) return 0; + + const sum = validResults.reduce((acc, result) => acc + result.angle, 0); + return sum / validResults.length; + } + + // Check if calibration is complete and valid + isCalibrationComplete(): boolean { + return this.calibrationResults.length >= 5; + } + + // Check if calibration has enough valid results + hasValidCalibration(): boolean { + return this.getValidCalibrationResults().length >= 4; // At least 4 out of 5 should be valid + } +} + +// Export singleton instance +export const calibrationService = CalibrationService.getInstance();