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,41 @@
import { LayoutChangeEvent, StyleSheet } from 'react-native';
import type { RefObject } from 'react';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import GAUSSIAN_SPLATS_HTML from '@/assets/webview/gaussian-splats.html';
type GaussianSplatWebViewProps = {
onMessage: (event: WebViewMessageEvent) => void;
onError: () => void;
webViewRef: RefObject<WebView | null>;
onLayout?: (event: LayoutChangeEvent) => void;
};
export const GaussianSplatWebView = ({
onMessage,
onError,
webViewRef,
onLayout,
}: GaussianSplatWebViewProps) => {
const html = typeof GAUSSIAN_SPLATS_HTML === 'string' ? GAUSSIAN_SPLATS_HTML : '';
return (
<WebView
ref={webViewRef}
source={{ html }}
originWhitelist={['*']}
allowFileAccess={false}
javaScriptEnabled
onMessage={onMessage}
onError={onError}
onLayout={onLayout}
style={styles.webView}
/>
);
};
const styles = StyleSheet.create({
webView: {
flex: 1,
backgroundColor: '#0A0E1A',
},
});
@@ -0,0 +1,316 @@
import { useCallback, useEffect, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import * as THREE from 'three';
import type { SensingFrame } from '@/types/sensing';
type GaussianSplatWebViewWebProps = {
onReady: () => void;
onFps: (fps: number) => void;
onError: (msg: string) => void;
frame: SensingFrame | null;
};
const BONES: [number, number][] = [
[0,1],[0,2],[1,3],[2,4],[5,6],[5,7],[7,9],[6,8],[8,10],
[5,11],[6,12],[11,12],[11,13],[13,15],[12,14],[14,16],
];
export const GaussianSplatWebViewWeb = ({ onReady, onFps, onError, frame }: GaussianSplatWebViewWebProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<{
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
joints: THREE.Mesh[];
boneLines: { line: THREE.Line; a: number; b: number }[];
ring: THREE.Mesh;
particleGeo: THREE.BufferGeometry;
pointLight: THREE.PointLight;
animId: number;
cameraAngle: number;
cameraRadius: number;
cameraY: number;
isDragging: boolean;
frameCount: number;
lastFpsTime: number;
} | null>(null);
const frameRef = useRef<SensingFrame | null>(null);
// Keep frame ref current without re-running effect
frameRef.current = frame;
const cleanup = useCallback(() => {
const s = sceneRef.current;
if (!s) return;
cancelAnimationFrame(s.animId);
s.renderer.dispose();
s.scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry.dispose();
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
else obj.material.dispose();
}
});
sceneRef.current = null;
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
try {
const W = () => container.clientWidth || window.innerWidth;
const H = () => container.clientHeight || window.innerHeight;
// Renderer
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(W(), H());
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x0a0e1a);
container.appendChild(renderer.domElement);
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e1a);
scene.fog = new THREE.FogExp2(0x0a0e1a, 0.008);
// Camera
const camera = new THREE.PerspectiveCamera(60, W() / H(), 0.1, 500);
camera.position.set(0, 2, 6);
camera.lookAt(0, 1, 0);
// Grid
const grid = new THREE.GridHelper(20, 40, 0x1a3a4a, 0x0d1f2a);
scene.add(grid);
// Lights
scene.add(new THREE.AmbientLight(0x32b8c6, 0.3));
const pointLight = new THREE.PointLight(0x32b8c6, 1.5, 20);
pointLight.position.set(0, 4, 0);
scene.add(pointLight);
// Skeleton joints (17 COCO keypoints)
const jointGeo = new THREE.SphereGeometry(0.06, 8, 8);
const joints: THREE.Mesh[] = [];
for (let i = 0; i < 17; i++) {
const mat = new THREE.MeshStandardMaterial({
color: 0x32b8c6,
emissive: 0x32b8c6,
emissiveIntensity: 0.6,
});
const m = new THREE.Mesh(jointGeo, mat);
m.visible = false;
scene.add(m);
joints.push(m);
}
// Bone lines
const boneMat = new THREE.LineBasicMaterial({
color: 0x32b8c6,
transparent: true,
opacity: 0.7,
});
const boneLines = BONES.map(([a, b]) => {
const g = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(),
new THREE.Vector3(),
]);
const l = new THREE.Line(g, boneMat);
l.visible = false;
scene.add(l);
return { line: l, a, b };
});
// Particle field
const N = 500;
const particleGeo = new THREE.BufferGeometry();
const pPos = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
pPos[i * 3] = (Math.random() - 0.5) * 16;
pPos[i * 3 + 1] = Math.random() * 4;
pPos[i * 3 + 2] = (Math.random() - 0.5) * 16;
}
particleGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
const pMat = new THREE.PointsMaterial({
color: 0x32b8c6,
size: 0.04,
transparent: true,
opacity: 0.4,
});
scene.add(new THREE.Points(particleGeo, pMat));
// Signal ring
const ringGeo = new THREE.TorusGeometry(2, 0.02, 8, 64);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x32b8c6,
transparent: true,
opacity: 0.3,
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.01;
scene.add(ring);
// State
const state = {
renderer,
scene,
camera,
joints,
boneLines,
ring,
particleGeo,
pointLight,
animId: 0,
cameraAngle: 0,
cameraRadius: 6,
cameraY: 2,
isDragging: false,
frameCount: 0,
lastFpsTime: performance.now(),
};
sceneRef.current = state;
// Mouse interaction
const canvas = renderer.domElement;
const onMouseDown = () => { state.isDragging = true; };
const onMouseUp = () => { state.isDragging = false; };
const onMouseMove = (e: MouseEvent) => {
if (state.isDragging) {
state.cameraAngle += e.movementX * 0.01;
state.cameraY = Math.max(0.5, Math.min(5, state.cameraY - e.movementY * 0.01));
}
};
const onWheel = (e: WheelEvent) => {
state.cameraRadius = Math.max(2, Math.min(15, state.cameraRadius + e.deltaY * 0.005));
};
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('wheel', onWheel, { passive: true });
// Resize
const onResize = () => {
camera.aspect = W() / H();
camera.updateProjectionMatrix();
renderer.setSize(W(), H());
};
window.addEventListener('resize', onResize);
// Animation loop
const animate = () => {
state.animId = requestAnimationFrame(animate);
const t = performance.now() * 0.001;
// Camera orbit
if (!state.isDragging) state.cameraAngle += 0.002;
camera.position.set(
Math.sin(state.cameraAngle) * state.cameraRadius,
state.cameraY,
Math.cos(state.cameraAngle) * state.cameraRadius,
);
camera.lookAt(0, 1, 0);
// Animate ring
ring.material.opacity = 0.15 + Math.sin(t * 2) * 0.1;
const scale = 1 + Math.sin(t) * 0.1;
ring.scale.set(scale, scale, 1);
// Animate particles
const pp = particleGeo.attributes.position as THREE.BufferAttribute;
for (let i = 0; i < N; i++) {
(pp.array as Float32Array)[i * 3 + 1] += Math.sin(t + i) * 0.001;
}
pp.needsUpdate = true;
// Update skeleton from frame data
const currentFrame = frameRef.current;
if (currentFrame) {
const persons = (currentFrame as any).persons || [];
if (persons.length > 0) {
const kps = persons[0].keypoints || [];
kps.forEach((kp: any, i: number) => {
if (i < 17 && joints[i]) {
joints[i].position.set(
(kp.x - 0.5) * 4,
(1 - kp.y) * 3,
(kp.z || 0) * 2,
);
joints[i].visible = kp.confidence > 0.3;
(joints[i].material as THREE.MeshStandardMaterial).emissiveIntensity =
0.3 + kp.confidence * 0.7;
}
});
boneLines.forEach(({ line, a, b }) => {
if (joints[a].visible && joints[b].visible) {
const pos = line.geometry.attributes.position as THREE.BufferAttribute;
pos.setXYZ(0, joints[a].position.x, joints[a].position.y, joints[a].position.z);
pos.setXYZ(1, joints[b].position.x, joints[b].position.y, joints[b].position.z);
pos.needsUpdate = true;
line.visible = true;
} else {
line.visible = false;
}
});
} else {
joints.forEach((j) => { j.visible = false; });
boneLines.forEach((bl) => { bl.line.visible = false; });
}
// Adjust light from RSSI
const features = (currentFrame as any).features;
if (features) {
const rssi = features.mean_rssi || -70;
pointLight.intensity = 1 + Math.abs(rssi + 50) * 0.02;
}
}
renderer.render(scene, camera);
// FPS counter
state.frameCount++;
if (performance.now() - state.lastFpsTime >= 1000) {
onFps(state.frameCount);
state.frameCount = 0;
state.lastFpsTime = performance.now();
}
};
animate();
onReady();
return () => {
canvas.removeEventListener('mousedown', onMouseDown);
canvas.removeEventListener('mouseup', onMouseUp);
canvas.removeEventListener('mousemove', onMouseMove);
canvas.removeEventListener('wheel', onWheel);
window.removeEventListener('resize', onResize);
cleanup();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
};
} catch (err) {
onError(err instanceof Error ? err.message : 'Failed to initialize 3D renderer');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<View style={styles.container}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%', backgroundColor: '#0a0e1a' }}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0a0e1a',
},
});
export default GaussianSplatWebViewWeb;
@@ -0,0 +1,164 @@
import { Pressable, StyleSheet, View } from 'react-native';
import { memo, useCallback, useState } from 'react';
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
import { StatusDot } from '@/components/StatusDot';
import { ModeBadge } from '@/components/ModeBadge';
import { ThemedText } from '@/components/ThemedText';
import { formatConfidence, formatRssi } from '@/utils/formatters';
import { colors, spacing } from '@/theme';
import type { ConnectionStatus } from '@/types/sensing';
type LiveMode = 'LIVE' | 'SIM' | 'RSSI';
type LiveHUDProps = {
rssi?: number;
connectionStatus: ConnectionStatus;
fps: number;
confidence: number;
personCount: number;
mode: LiveMode;
};
const statusTextMap: Record<ConnectionStatus, string> = {
connected: 'Connected',
simulated: 'Simulated',
connecting: 'Connecting',
disconnected: 'Disconnected',
};
const statusDotStatusMap: Record<ConnectionStatus, 'connected' | 'simulated' | 'disconnected' | 'connecting'> = {
connected: 'connected',
simulated: 'simulated',
connecting: 'connecting',
disconnected: 'disconnected',
};
export const LiveHUD = memo(
({ rssi, connectionStatus, fps, confidence, personCount, mode }: LiveHUDProps) => {
const [panelVisible, setPanelVisible] = useState(true);
const panelAlpha = useSharedValue(1);
const togglePanel = useCallback(() => {
const next = !panelVisible;
setPanelVisible(next);
panelAlpha.value = withTiming(next ? 1 : 0, { duration: 220 });
}, [panelAlpha, panelVisible]);
const animatedPanelStyle = useAnimatedStyle(() => ({
opacity: panelAlpha.value,
}));
const statusText = statusTextMap[connectionStatus];
return (
<Pressable style={StyleSheet.absoluteFill} onPress={togglePanel}>
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, animatedPanelStyle]}>
{/* App title */}
<View style={styles.topLeft}>
<ThemedText preset="labelLg" style={styles.appTitle}>
WiFi-DensePose
</ThemedText>
</View>
{/* Status + FPS */}
<View style={styles.topRight}>
<View style={styles.row}>
<StatusDot status={statusDotStatusMap[connectionStatus]} size={10} />
<ThemedText preset="labelMd" style={styles.statusText}>
{statusText}
</ThemedText>
</View>
{fps > 0 && (
<View style={styles.row}>
<ThemedText preset="labelMd">{fps} FPS</ThemedText>
</View>
)}
</View>
{/* Bottom panel */}
<View style={styles.bottomPanel}>
<View style={styles.bottomCell}>
<ThemedText preset="bodySm">RSSI</ThemedText>
<ThemedText preset="displayMd" style={styles.bigValue}>
{formatRssi(rssi)}
</ThemedText>
</View>
<View style={styles.bottomCell}>
<ModeBadge mode={mode} />
</View>
<View style={styles.bottomCellRight}>
<ThemedText preset="bodySm">Confidence</ThemedText>
<ThemedText preset="bodyMd" style={styles.metaText}>
{formatConfidence(confidence)}
</ThemedText>
<ThemedText preset="bodySm">People: {personCount}</ThemedText>
</View>
</View>
</Animated.View>
</Pressable>
);
},
);
const styles = StyleSheet.create({
topLeft: {
position: 'absolute',
top: spacing.md,
left: spacing.md,
},
appTitle: {
color: colors.textPrimary,
},
topRight: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
alignItems: 'flex-end',
gap: 4,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
statusText: {
color: colors.textPrimary,
},
bottomPanel: {
position: 'absolute',
left: spacing.sm,
right: spacing.sm,
bottom: spacing.sm,
minHeight: 72,
borderRadius: 12,
backgroundColor: 'rgba(10,14,26,0.72)',
borderWidth: 1,
borderColor: 'rgba(50,184,198,0.35)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
bottomCell: {
flex: 1,
alignItems: 'center',
},
bottomCellRight: {
flex: 1,
alignItems: 'flex-end',
},
bigValue: {
color: colors.accent,
marginTop: 2,
marginBottom: 2,
},
metaText: {
color: colors.textPrimary,
marginBottom: 4,
},
});
LiveHUD.displayName = 'LiveHUD';
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Platform, StyleSheet, View } from 'react-native';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { usePoseStream } from '@/hooks/usePoseStream';
import { colors, spacing } from '@/theme';
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
import { LiveHUD } from './LiveHUD';
type LiveMode = 'LIVE' | 'SIM' | 'RSSI';
const getMode = (
status: ConnectionStatus,
isSimulated: boolean,
frame: SensingFrame | null,
): LiveMode => {
if (isSimulated || frame?.source === 'simulated') return 'SIM';
if (status === 'connected') return 'LIVE';
return 'RSSI';
};
const isWeb = Platform.OS === 'web';
type ViewerProps = {
frame: SensingFrame | null;
onReady: () => void;
onFps: (fps: number) => void;
onError: (msg: string) => void;
};
const WebLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
const [Viewer, setViewer] = useState<React.ComponentType<any> | null>(null);
useEffect(() => {
import('./GaussianSplatWebView.web').then((mod) => {
setViewer(() => mod.GaussianSplatWebViewWeb);
}).catch(() => onError('Failed to load web viewer'));
}, [onError]);
if (!Viewer) return null;
return <Viewer frame={frame} onReady={onReady} onFps={onFps} onError={onError} />;
};
const NativeLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
const webViewRef = useRef(null);
const [WVComponent, setWVComponent] = useState<React.ComponentType<any> | null>(null);
useEffect(() => {
try {
const { GaussianSplatWebView } = require('./GaussianSplatWebView');
setWVComponent(() => GaussianSplatWebView);
} catch {
onError('WebView not available on this platform');
}
}, [onError]);
if (!WVComponent) return null;
return (
<WVComponent
webViewRef={webViewRef}
onMessage={(event: any) => {
try {
const data = typeof event.nativeEvent.data === 'string'
? JSON.parse(event.nativeEvent.data)
: event.nativeEvent.data;
if (data.type === 'READY') onReady();
else if (data.type === 'FPS_TICK') onFps(data.payload?.fps ?? 0);
else if (data.type === 'ERROR') onError(data.payload?.message ?? 'Unknown error');
} catch { /* ignore */ }
}}
onError={() => onError('WebView renderer failed')}
/>
);
};
export const LiveScreen = () => {
const { lastFrame, connectionStatus, isSimulated } = usePoseStream();
const [ready, setReady] = useState(false);
const [fps, setFps] = useState(0);
const [error, setError] = useState<string | null>(null);
const [viewerKey, setViewerKey] = useState(0);
const handleReady = useCallback(() => { setReady(true); setError(null); }, []);
const handleFps = useCallback((f: number) => setFps(Math.max(0, Math.floor(f))), []);
const handleError = useCallback((msg: string) => { setError(msg); setReady(false); }, []);
const handleRetry = useCallback(() => { setError(null); setReady(false); setFps(0); setViewerKey((v) => v + 1); }, []);
const rssi = lastFrame?.features?.mean_rssi;
const personCount = lastFrame?.classification?.presence ? 1 : 0;
const mode = getMode(connectionStatus, isSimulated, lastFrame);
if (error) {
return (
<ThemedView style={styles.fallbackWrap}>
<ThemedText preset="bodyLg">Live visualization failed</ThemedText>
<ThemedText preset="bodySm" color="textSecondary" style={styles.errorText}>{error}</ThemedText>
<Button title="Retry" onPress={handleRetry} />
</ThemedView>
);
}
return (
<ErrorBoundary>
<View style={styles.container}>
{isWeb ? (
<WebLiveViewer key={viewerKey} frame={lastFrame} onReady={handleReady} onFps={handleFps} onError={handleError} />
) : (
<NativeLiveViewer key={viewerKey} frame={lastFrame} onReady={handleReady} onFps={handleFps} onError={handleError} />
)}
<LiveHUD
connectionStatus={connectionStatus}
fps={fps}
rssi={rssi}
confidence={lastFrame?.classification?.confidence ?? 0}
personCount={personCount}
mode={mode}
/>
{!ready && (
<View style={styles.loadingWrap}>
<LoadingSpinner />
<ThemedText preset="bodyMd" style={styles.loadingText}>Loading live renderer</ThemedText>
</View>
)}
</View>
</ErrorBoundary>
);
};
export default LiveScreen;
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
loadingWrap: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center', gap: spacing.md },
loadingText: { color: colors.textSecondary },
fallbackWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.lg },
errorText: { textAlign: 'center' },
});
@@ -0,0 +1,97 @@
import { useCallback, useState } from 'react';
import type { RefObject } from 'react';
import type { WebViewMessageEvent } from 'react-native-webview';
import { WebView } from 'react-native-webview';
import type { SensingFrame } from '@/types/sensing';
export type GaussianBridgeMessageType = 'READY' | 'FPS_TICK' | 'ERROR';
type BridgeMessage = {
type: GaussianBridgeMessageType;
payload?: {
fps?: number;
message?: string;
};
};
const toJsonScript = (message: unknown): string => {
const serialized = JSON.stringify(message);
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(serialized)} })); true;`;
};
export const useGaussianBridge = (webViewRef: RefObject<WebView | null>) => {
const [isReady, setIsReady] = useState(false);
const [fps, setFps] = useState(0);
const [error, setError] = useState<string | null>(null);
const send = useCallback((message: unknown) => {
const webView = webViewRef.current;
if (!webView) {
return;
}
webView.injectJavaScript(toJsonScript(message));
}, [webViewRef]);
const sendFrame = useCallback(
(frame: SensingFrame) => {
send({
type: 'FRAME_UPDATE',
payload: frame,
});
},
[send],
);
const onMessage = useCallback((event: WebViewMessageEvent) => {
let parsed: BridgeMessage | null = null;
const raw = event.nativeEvent.data;
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw) as BridgeMessage;
} catch {
setError('Invalid bridge message format');
return;
}
} else if (typeof raw === 'object' && raw !== null) {
parsed = raw as BridgeMessage;
}
if (!parsed) {
return;
}
if (parsed.type === 'READY') {
setIsReady(true);
setError(null);
return;
}
if (parsed.type === 'FPS_TICK') {
const fpsValue = parsed.payload?.fps;
if (typeof fpsValue === 'number' && Number.isFinite(fpsValue)) {
setFps(Math.max(0, Math.floor(fpsValue)));
}
return;
}
if (parsed.type === 'ERROR') {
setError(parsed.payload?.message ?? 'Unknown bridge error');
setIsReady(false);
}
}, []);
return {
sendFrame,
onMessage,
isReady,
fps,
error,
reset: () => {
setIsReady(false);
setFps(0);
setError(null);
},
};
};