mirror of
https://github.com/ruvnet/RuView
synced 2026-07-30 18:41:42 +00:00
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:
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user