feat: Implement RSSI service for iOS and Web platforms

- Added IosRssiService to handle synthetic RSSI data for iOS.
- Created WebRssiService to simulate RSSI scanning on the web.
- Defined shared types for WifiNetwork and RssiService in rssi.service.ts.
- Introduced simulation service to generate synthetic sensing data.
- Implemented WebSocket service for real-time data handling with reconnection logic.
- Established Zustand stores for managing application state related to MAT and pose data.
- Developed theme context and utility functions for consistent styling and formatting.
- Added type definitions for various application entities including API responses and sensing data.
- Created utility functions for color mapping and URL validation.
- Configured TypeScript settings for the mobile application.
This commit is contained in:
ruv
2026-03-02 10:30:33 -05:00
parent 02192b0232
commit fdc7142dfa
131 changed files with 24090 additions and 0 deletions
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { View, StyleSheet } from 'react-native';
import { usePoseStore } from '@/stores/poseStore';
import { GaugeArc } from '@/components/GaugeArc';
import { colors } from '@/theme/colors';
import { ThemedText } from '@/components/ThemedText';
const BREATHING_MIN_BPM = 0;
const BREATHING_MAX_BPM = 30;
const BREATHING_BAND_MAX = 0.3;
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const deriveBreathingValue = (
breathingBand?: number,
breathingBpm?: number,
): number => {
if (typeof breathingBpm === 'number' && Number.isFinite(breathingBpm)) {
return clamp(breathingBpm, BREATHING_MIN_BPM, BREATHING_MAX_BPM);
}
const bandValue = typeof breathingBand === 'number' && Number.isFinite(breathingBand) ? breathingBand : 0;
const normalized = clamp(bandValue / BREATHING_BAND_MAX, 0, 1);
return normalized * BREATHING_MAX_BPM;
};
export const BreathingGauge = () => {
const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
const breathingBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.breathing_bpm);
const value = useMemo(
() => deriveBreathingValue(breathingBand, breathingBpm),
[breathingBand, breathingBpm],
);
return (
<View style={styles.container}>
<ThemedText preset="labelMd" style={styles.label}>
BREATHING
</ThemedText>
<GaugeArc value={value} min={BREATHING_MIN_BPM} max={BREATHING_MAX_BPM} label="" unit="BPM" color={colors.accent} />
<ThemedText preset="labelMd" color="textSecondary" style={styles.unit}>
BPM
</ThemedText>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
label: {
color: '#94A3B8',
letterSpacing: 1,
},
unit: {
marginTop: -12,
marginBottom: 4,
},
});
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import { usePoseStore } from '@/stores/poseStore';
import { GaugeArc } from '@/components/GaugeArc';
import { colors } from '@/theme/colors';
import { ThemedText } from '@/components/ThemedText';
const HEART_MIN_BPM = 40;
const HEART_MAX_BPM = 120;
const MOTION_BAND_MAX = 0.5;
const BREATH_BAND_MAX = 0.3;
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const deriveHeartRate = (
heartbeat?: number,
motionBand?: number,
breathingBand?: number,
): number => {
if (typeof heartbeat === 'number' && Number.isFinite(heartbeat)) {
return clamp(heartbeat, HEART_MIN_BPM, HEART_MAX_BPM);
}
const motionValue = typeof motionBand === 'number' && Number.isFinite(motionBand) ? clamp(motionBand / MOTION_BAND_MAX, 0, 1) : 0;
const breathValue = typeof breathingBand === 'number' && Number.isFinite(breathingBand) ? clamp(breathingBand / BREATH_BAND_MAX, 0, 1) : 0;
const normalized = 0.7 * motionValue + 0.3 * breathValue;
return HEART_MIN_BPM + normalized * (HEART_MAX_BPM - HEART_MIN_BPM);
};
export const HeartRateGauge = () => {
const heartProxyBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.hr_proxy_bpm);
const motionBand = usePoseStore((state) => state.features?.motion_band_power);
const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
const value = useMemo(
() => deriveHeartRate(heartProxyBpm, motionBand, breathingBand),
[heartProxyBpm, motionBand, breathingBand],
);
return (
<View style={styles.container}>
<ThemedText preset="labelMd" style={styles.label}>
HR PROXY
</ThemedText>
<GaugeArc
value={value}
min={HEART_MIN_BPM}
max={HEART_MAX_BPM}
label=""
unit="BPM"
color={colors.danger}
colorTo={colors.success}
/>
<ThemedText preset="bodySm" color="textSecondary" style={styles.note}>
(estimated)
</ThemedText>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
label: {
color: '#94A3B8',
letterSpacing: 1,
},
note: {
marginTop: -12,
marginBottom: 4,
},
});
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { SparklineChart } from '@/components/SparklineChart';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
type MetricCardProps = {
label: string;
value: number | string;
unit?: string;
color?: string;
sparklineData?: number[];
};
const formatMetricValue = (value: number, unit?: string) => {
if (!Number.isFinite(value)) {
return '--';
}
const decimals = Math.abs(value) >= 100 ? 0 : Math.abs(value) >= 10 ? 2 : 3;
const text = value.toFixed(decimals);
return unit ? `${text} ${unit}` : text;
};
export const MetricCard = ({ label, value, unit, color = colors.accent, sparklineData }: MetricCardProps) => {
const numericValue = typeof value === 'number' ? value : null;
const [displayValue, setDisplayValue] = useState(() =>
numericValue !== null ? formatMetricValue(numericValue, unit) : String(value ?? '--'),
);
const valueAnimation = useSharedValue(numericValue ?? 0);
const finalValue = useMemo(
() => (numericValue !== null ? numericValue : NaN),
[numericValue],
);
useEffect(() => {
if (numericValue === null) {
setDisplayValue(String(value ?? '--'));
return;
}
valueAnimation.value = withSpring(finalValue, {
damping: 18,
stiffness: 160,
mass: 1,
});
}, [finalValue, numericValue, value, valueAnimation]);
useAnimatedReaction(
() => valueAnimation.value,
(current) => {
runOnJS(setDisplayValue)(formatMetricValue(current, unit));
},
[unit],
);
return (
<View style={[styles.card, { borderColor: color, shadowColor: color, shadowOpacity: 0.35 }]} accessibilityRole="summary">
<ThemedText preset="labelMd" style={styles.label}>
{label}
</ThemedText>
<ThemedText preset="displayMd" style={styles.value}>
{displayValue}
</ThemedText>
{sparklineData && sparklineData.length > 0 && (
<View style={styles.sparklineWrap}>
<SparklineChart data={sparklineData} color={color} height={56} />
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderWidth: 1,
borderRadius: 14,
padding: 12,
marginBottom: 10,
gap: 6,
shadowOffset: {
width: 0,
height: 0,
},
shadowRadius: 12,
elevation: 4,
},
label: {
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
value: {
color: colors.textPrimary,
marginBottom: 2,
},
sparklineWrap: {
marginTop: 4,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 8,
},
});
@@ -0,0 +1,206 @@
import { useEffect } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { BreathingGauge } from './BreathingGauge';
import { HeartRateGauge } from './HeartRateGauge';
import { MetricCard } from './MetricCard';
import { ConnectionBanner } from '@/components/ConnectionBanner';
import { ModeBadge } from '@/components/ModeBadge';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { SparklineChart } from '@/components/SparklineChart';
import { usePoseStore } from '@/stores/poseStore';
import { usePoseStream } from '@/hooks/usePoseStream';
import { colors } from '@/theme/colors';
type ConnectionBannerState = 'connected' | 'simulated' | 'disconnected';
const clampPercent = (value: number) => {
const normalized = Number.isFinite(value) ? value : 0;
return Math.max(0, Math.min(1, normalized > 1 ? normalized / 100 : normalized));
};
export default function VitalsScreen() {
usePoseStream();
const connectionStatus = usePoseStore((state) => state.connectionStatus);
const isSimulated = usePoseStore((state) => state.isSimulated);
const features = usePoseStore((state) => state.features);
const classification = usePoseStore((state) => state.classification);
const rssiHistory = usePoseStore((state) => state.rssiHistory);
const confidence = clampPercent(classification?.confidence ?? 0);
const badgeLabel = (classification?.motion_level ?? 'ABSENT').toUpperCase();
const bannerStatus: ConnectionBannerState = connectionStatus === 'connected' ? 'connected' : connectionStatus === 'simulated' ? 'simulated' : 'disconnected';
const confidenceProgress = useSharedValue(0);
useEffect(() => {
confidenceProgress.value = withSpring(confidence, {
damping: 16,
stiffness: 150,
mass: 1,
});
}, [confidence, confidenceProgress]);
const animatedConfidenceStyle = useAnimatedStyle(() => ({
width: `${confidenceProgress.value * 100}%`,
}));
const classificationColor =
classification?.motion_level === 'active'
? colors.success
: classification?.motion_level === 'present_still'
? colors.warn
: colors.muted;
return (
<ThemedView style={styles.screen}>
<ConnectionBanner status={bannerStatus} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerRow}>{isSimulated ? <ModeBadge mode="SIM" /> : null}</View>
<View style={styles.gaugesRow}>
<View style={styles.gaugeCard}>
<BreathingGauge />
</View>
<View style={styles.gaugeCard}>
<HeartRateGauge />
</View>
</View>
<View style={styles.section}>
<ThemedText preset="labelLg" color="textSecondary">
RSSI HISTORY
</ThemedText>
<SparklineChart data={rssiHistory.length > 0 ? rssiHistory : [0]} color={colors.accent} />
</View>
<MetricCard label="Variance" value={features?.variance ?? 0} unit="" sparklineData={rssiHistory} color={colors.accent} />
<MetricCard
label="Motion Band"
value={features?.motion_band_power ?? 0}
unit=""
color={colors.success}
/>
<MetricCard
label="Breath Band"
value={features?.breathing_band_power ?? 0}
unit=""
color={colors.warn}
/>
<MetricCard
label="Spectral Entropy"
value={features?.spectral_entropy ?? 0}
unit=""
color={colors.connected}
/>
<View style={styles.classificationSection}>
<ThemedText preset="labelLg" style={styles.rowLabel}>
Classification: {badgeLabel}
</ThemedText>
<View style={[styles.badgePill, { borderColor: classificationColor, backgroundColor: `${classificationColor}18` }]}>
<ThemedText preset="labelMd" style={{ color: classificationColor }}>
{badgeLabel}
</ThemedText>
</View>
<View style={styles.confidenceContainer}>
<ThemedText preset="bodySm" color="textSecondary">
Confidence
</ThemedText>
<View style={styles.confidenceBarTrack}>
<Animated.View style={[styles.confidenceBarFill, animatedConfidenceStyle]} />
</View>
<ThemedText preset="bodySm">{Math.round(confidence * 100)}%</ThemedText>
</View>
</View>
</ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: colors.bg,
paddingTop: 40,
paddingHorizontal: 12,
},
content: {
paddingTop: 12,
paddingBottom: 30,
gap: 12,
},
headerRow: {
alignItems: 'flex-end',
},
gaugesRow: {
flexDirection: 'row',
gap: 12,
},
gaugeCard: {
flex: 1,
backgroundColor: '#111827',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(50,184,198,0.45)',
paddingVertical: 10,
paddingHorizontal: 8,
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.accent,
shadowOpacity: 0.3,
shadowOffset: {
width: 0,
height: 0,
},
shadowRadius: 12,
elevation: 4,
},
section: {
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(50,184,198,0.35)',
padding: 12,
gap: 10,
},
classificationSection: {
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(50,184,198,0.35)',
padding: 12,
gap: 10,
marginBottom: 6,
},
rowLabel: {
color: colors.textSecondary,
marginBottom: 8,
},
badgePill: {
alignSelf: 'flex-start',
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
marginBottom: 4,
},
confidenceContainer: {
gap: 6,
},
confidenceBarTrack: {
height: 10,
borderRadius: 999,
backgroundColor: colors.surfaceAlt,
overflow: 'hidden',
},
confidenceBarFill: {
height: '100%',
backgroundColor: colors.success,
borderRadius: 999,
},
});