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
+92
View File
@@ -0,0 +1,92 @@
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios';
import { API_POSE_FRAMES_PATH, API_POSE_STATUS_PATH, API_POSE_ZONES_PATH } from '@/constants/api';
import type { ApiError, HistoricalFrames, PoseStatus, ZoneConfig } from '@/types/api';
class ApiService {
private baseUrl = '';
private client: AxiosInstance;
constructor() {
this.client = axios.create({
timeout: 5000,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
});
}
setBaseUrl(url: string): void {
this.baseUrl = url ?? '';
}
private buildUrl(path: string): string {
if (!this.baseUrl) {
return path;
}
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const normalized = this.baseUrl.replace(/\/$/, '');
return `${normalized}${path.startsWith('/') ? path : `/${path}`}`;
}
private normalizeError(error: unknown): ApiError {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ message?: string }>;
const message =
axiosError.response?.data && typeof axiosError.response.data === 'object' && 'message' in axiosError.response.data
? String((axiosError.response.data as { message?: string }).message)
: axiosError.message || 'Request failed';
return {
message,
status: axiosError.response?.status,
code: axiosError.code,
details: axiosError.response?.data,
};
}
if (error instanceof Error) {
return { message: error.message };
}
return { message: 'Unknown error' };
}
private async requestWithRetry<T>(config: AxiosRequestConfig, retriesLeft: number): Promise<T> {
try {
const response = await this.client.request<T>({
...config,
url: this.buildUrl(config.url || ''),
});
return response.data;
} catch (error) {
if (retriesLeft > 0) {
return this.requestWithRetry<T>(config, retriesLeft - 1);
}
throw this.normalizeError(error);
}
}
get<T>(path: string): Promise<T> {
return this.requestWithRetry<T>({ method: 'GET', url: path }, 2);
}
post<T>(path: string, body: unknown): Promise<T> {
return this.requestWithRetry<T>({ method: 'POST', url: path, data: body }, 2);
}
getStatus(): Promise<PoseStatus> {
return this.get<PoseStatus>(API_POSE_STATUS_PATH);
}
getZones(): Promise<ZoneConfig[]> {
return this.get<ZoneConfig[]>(API_POSE_ZONES_PATH);
}
getFrames(limit: number): Promise<HistoricalFrames> {
return this.get<HistoricalFrames>(`${API_POSE_FRAMES_PATH}?limit=${encodeURIComponent(String(limit))}`);
}
}
export const apiService = new ApiService();
@@ -0,0 +1,62 @@
import type { RssiService, WifiNetwork } from './rssi.service';
import WifiManager from '@react-native-wifi-reborn';
type NativeWifiNetwork = {
SSID?: string;
BSSID?: string;
level?: number;
levelDbm?: number;
};
class AndroidRssiService implements RssiService {
private timer: ReturnType<typeof setInterval> | null = null;
private listeners = new Set<(networks: WifiNetwork[]) => void>();
startScanning(intervalMs: number): void {
this.stopScanning();
this.scanOnce();
this.timer = setInterval(() => {
this.scanOnce();
}, intervalMs);
}
stopScanning(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private async scanOnce(): Promise<void> {
try {
const results = (await WifiManager.loadWifiList()) as NativeWifiNetwork[];
const mapped = results.map((item) => ({
ssid: item.SSID || '',
bssid: item.BSSID,
level: typeof item.level === 'number' ? item.level : typeof item.levelDbm === 'number' ? item.levelDbm : -100,
}));
this.broadcast(mapped.filter((n) => n.ssid.length > 0));
} catch {
this.broadcast([]);
}
}
private broadcast(networks: WifiNetwork[]): void {
this.listeners.forEach((listener) => {
try {
listener(networks);
} catch {
// listener safety
}
});
}
}
export const rssiService = new AndroidRssiService();
@@ -0,0 +1,41 @@
import type { RssiService, WifiNetwork } from './rssi.service';
class IosRssiService implements RssiService {
private timer: ReturnType<typeof setInterval> | null = null;
private listeners = new Set<(networks: WifiNetwork[]) => void>();
startScanning(intervalMs: number): void {
console.warn('iOS RSSI scanning not available; returning synthetic network data.');
this.stopScanning();
this.timer = setInterval(() => {
this.broadcast([{ ssid: 'WiFi-DensePose', bssid: undefined, level: -60 }]);
}, intervalMs);
this.broadcast([{ ssid: 'WiFi-DensePose', bssid: undefined, level: -60 }]);
}
stopScanning(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private broadcast(networks: WifiNetwork[]): void {
this.listeners.forEach((listener) => {
try {
listener(networks);
} catch {
// listener safety
}
});
}
}
export const rssiService = new IosRssiService();
+33
View File
@@ -0,0 +1,33 @@
export interface WifiNetwork {
ssid: string;
bssid?: string;
level: number;
}
export interface RssiService {
startScanning(intervalMs: number): void;
stopScanning(): void;
subscribe(listener: (networks: WifiNetwork[]) => void): () => void;
}
// Metro resolves the correct platform file automatically:
// rssi.service.android.ts (Android)
// rssi.service.ios.ts (iOS)
// rssi.service.web.ts (Web)
// This file only exports the shared types.
// The platform entry is re-exported from the index barrel below.
import { Platform } from 'react-native';
// Lazy require to avoid bundling native modules on web
function getPlatformService(): RssiService {
if (Platform.OS === 'android') {
return require('./rssi.service.android').rssiService;
} else if (Platform.OS === 'ios') {
return require('./rssi.service.ios').rssiService;
} else {
return require('./rssi.service.web').rssiService;
}
}
export const rssiService: RssiService = getPlatformService();
@@ -0,0 +1,46 @@
import type { RssiService, WifiNetwork } from './rssi.service';
class WebRssiService implements RssiService {
private timer: ReturnType<typeof setInterval> | null = null;
private listeners = new Set<(networks: WifiNetwork[]) => void>();
startScanning(intervalMs: number): void {
console.warn('Web RSSI scanning not available; returning synthetic network data.');
this.stopScanning();
this.timer = setInterval(() => {
this.broadcast([
{ ssid: 'WiFi-DensePose', bssid: 'AA:BB:CC:DD:EE:01', level: -55 },
{ ssid: 'WiFi-Guest', bssid: 'AA:BB:CC:DD:EE:02', level: -72 },
]);
}, intervalMs);
this.broadcast([
{ ssid: 'WiFi-DensePose', bssid: 'AA:BB:CC:DD:EE:01', level: -55 },
]);
}
stopScanning(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private broadcast(networks: WifiNetwork[]): void {
this.listeners.forEach((listener) => {
try {
listener(networks);
} catch {
// listener safety
}
});
}
}
export const rssiService = new WebRssiService();
@@ -0,0 +1,107 @@
import {
BREATHING_BAND_AMPLITUDE,
BREATHING_BAND_MIN,
BREATHING_BPM_MAX,
BREATHING_BPM_MIN,
HEART_BPM_MAX,
HEART_BPM_MIN,
MOTION_BAND_AMPLITUDE,
MOTION_BAND_MIN,
RSSI_AMPLITUDE_DBM,
RSSI_BASE_DBM,
SIMULATION_GRID_SIZE,
SIMULATION_TICK_INTERVAL_MS,
SIGNAL_FIELD_PRESENCE_LEVEL,
VARIANCE_AMPLITUDE,
VARIANCE_BASE,
} from '@/constants/simulation';
import type { SensingFrame } from '@/types/sensing';
function gaussian(x: number, y: number, cx: number, cy: number, sigma: number): number {
const dx = x - cx;
const dy = y - cy;
return Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));
}
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
export function generateSimulatedData(timeMs = Date.now()): SensingFrame {
const t = timeMs / 1000;
const baseRssi = RSSI_BASE_DBM + Math.sin(t * 0.5) * RSSI_AMPLITUDE_DBM;
const variance = VARIANCE_BASE + Math.sin(t * 0.1) * VARIANCE_AMPLITUDE;
const motionBand = MOTION_BAND_MIN + Math.abs(Math.sin(t * 0.3)) * MOTION_BAND_AMPLITUDE;
const breathingBand = BREATHING_BAND_MIN + Math.abs(Math.sin(t * 0.05)) * BREATHING_BAND_AMPLITUDE;
const isPresent = variance > SIGNAL_FIELD_PRESENCE_LEVEL;
const isActive = motionBand > 0.12;
const grid = SIMULATION_GRID_SIZE;
const cx = grid / 2;
const cy = grid / 2;
const bodyX = cx + 3 * Math.sin(t * 0.2);
const bodyY = cy + 2 * Math.cos(t * 0.15);
const breathX = cx + 4 * Math.sin(t * 0.04);
const breathY = cy + 4 * Math.cos(t * 0.04);
const values: number[] = [];
for (let z = 0; z < grid; z += 1) {
for (let x = 0; x < grid; x += 1) {
let value = Math.max(0, 1 - Math.sqrt((x - cx) ** 2 + (z - cy) ** 2) / (grid * 0.7)) * 0.3;
value += gaussian(x, z, bodyX, bodyY, 3.4) * (0.3 + motionBand * 3);
value += gaussian(x, z, breathX, breathY, 6) * (0.15 + breathingBand * 2);
if (!isPresent) {
value *= 0.7;
}
values.push(clamp(value, 0, 1));
}
}
const dominantFreqHz = 0.3 + Math.sin(t * 0.02) * 0.1;
const breathingBpm = BREATHING_BPM_MIN + ((Math.sin(t * 0.07) + 1) * 0.5) * (BREATHING_BPM_MAX - BREATHING_BPM_MIN);
const hrProxy = HEART_BPM_MIN + ((Math.sin(t * 0.09) + 1) * 0.5) * (HEART_BPM_MAX - HEART_BPM_MIN);
const confidence = 0.6 + Math.abs(Math.sin(t * 0.03)) * 0.4;
return {
type: 'sensing_update',
timestamp: timeMs,
source: 'simulated',
tick: Math.floor(t / (SIMULATION_TICK_INTERVAL_MS / 1000)),
nodes: [
{
node_id: 1,
rssi_dbm: baseRssi,
position: [2, 0, 1.5],
amplitude: [baseRssi],
subcarrier_count: 1,
},
],
features: {
mean_rssi: baseRssi,
variance,
motion_band_power: motionBand,
breathing_band_power: breathingBand,
spectral_entropy: 1 - clamp(Math.abs(dominantFreqHz - 0.3), 0, 1),
std: Math.sqrt(Math.abs(variance)),
dominant_freq_hz: dominantFreqHz,
change_points: Math.max(0, Math.floor(variance * 2)),
spectral_power: motionBand + breathingBand,
},
classification: {
motion_level: isActive ? 'active' : isPresent ? 'present_still' : 'absent',
presence: isPresent,
confidence: isPresent ? 0.75 + Math.abs(Math.sin(t * 0.03)) * 0.2 : 0.5 + Math.abs(Math.cos(t * 0.03)) * 0.3,
},
signal_field: {
grid_size: [grid, 1, grid],
values,
},
vital_signs: {
breathing_bpm: breathingBpm,
hr_proxy_bpm: hrProxy,
confidence,
},
};
}
+170
View File
@@ -0,0 +1,170 @@
import { SIMULATION_TICK_INTERVAL_MS } from '@/constants/simulation';
import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH } from '@/constants/websocket';
import { usePoseStore } from '@/stores/poseStore';
import { generateSimulatedData } from '@/services/simulation.service';
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
type FrameListener = (frame: SensingFrame) => void;
class WsService {
private ws: WebSocket | null = null;
private listeners = new Set<FrameListener>();
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private simulationTimer: ReturnType<typeof setInterval> | null = null;
private targetUrl = '';
private active = false;
private status: ConnectionStatus = 'disconnected';
connect(url: string): void {
this.targetUrl = url;
this.active = true;
this.reconnectAttempt = 0;
if (!url) {
this.handleStatusChange('simulated');
this.startSimulation();
return;
}
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.handleStatusChange('connecting');
try {
const endpoint = this.buildWsUrl(url);
const socket = new WebSocket(endpoint);
this.ws = socket;
socket.onopen = () => {
this.reconnectAttempt = 0;
this.stopSimulation();
this.handleStatusChange('connected');
};
socket.onmessage = (evt) => {
try {
const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data);
const frame = JSON.parse(raw) as SensingFrame;
this.listeners.forEach((listener) => listener(frame));
} catch {
// ignore malformed frames
}
};
socket.onerror = () => {
// handled by onclose
};
socket.onclose = (evt) => {
this.ws = null;
if (!this.active) {
this.handleStatusChange('disconnected');
return;
}
if (evt.code === 1000) {
this.handleStatusChange('disconnected');
return;
}
this.scheduleReconnect();
};
} catch {
this.scheduleReconnect();
}
}
disconnect(): void {
this.active = false;
this.clearReconnectTimer();
this.stopSimulation();
if (this.ws) {
this.ws.close(1000, 'client disconnect');
this.ws = null;
}
this.handleStatusChange('disconnected');
}
subscribe(listener: FrameListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getStatus(): ConnectionStatus {
return this.status;
}
private buildWsUrl(rawUrl: string): string {
const parsed = new URL(rawUrl);
const proto = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:';
// Sensing server runs WS on port 3001 at /ws/sensing
// If the HTTP server is on port 3000, connect WS to 3001
const wsHost = parsed.port === '3000'
? `${parsed.hostname}:3001`
: parsed.host;
const wsPath = parsed.port === '3000' ? '/ws/sensing' : WS_PATH;
return `${proto}//${wsHost}${wsPath}`;
}
private handleStatusChange(status: ConnectionStatus): void {
if (status === this.status) {
return;
}
this.status = status;
usePoseStore.getState().setConnectionStatus(status);
}
private scheduleReconnect(): void {
if (!this.active) {
this.handleStatusChange('disconnected');
return;
}
if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
this.handleStatusChange('simulated');
this.startSimulation();
return;
}
const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
this.reconnectAttempt += 1;
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect(this.targetUrl);
}, delay);
this.startSimulation();
}
private startSimulation(): void {
if (this.simulationTimer) {
return;
}
this.simulationTimer = setInterval(() => {
this.handleStatusChange('simulated');
const frame = generateSimulatedData();
this.listeners.forEach((listener) => {
listener(frame);
});
}, SIMULATION_TICK_INTERVAL_MS);
}
private stopSimulation(): void {
if (this.simulationTimer) {
clearInterval(this.simulationTimer);
this.simulationTimer = null;
}
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
export const wsService = new WsService();