diff --git a/ui/mobile/src/__tests__/screens/ZonesOccupancy.test.ts b/ui/mobile/src/__tests__/screens/ZonesOccupancy.test.ts new file mode 100644 index 000000000..44fee78e1 --- /dev/null +++ b/ui/mobile/src/__tests__/screens/ZonesOccupancy.test.ts @@ -0,0 +1,19 @@ +import { deriveFieldPositions } from '@/screens/ZonesScreen/useOccupancyGrid'; + +describe('Zones hardware occupancy mapping', () => { + it('places an estimated person at the strongest field cell', () => { + const values = new Array(400).fill(0); + values[7 * 20 + 12] = 1; + expect(deriveFieldPositions(values, 1)).toEqual([{ x: 12, y: 7 }]); + }); + + it('keeps multiple estimated people spatially separated', () => { + const values = new Array(400).fill(0); + values[2 * 20 + 2] = 1; + values[15 * 20 + 16] = 0.9; + const positions = deriveFieldPositions(values, 2); + expect(positions).toHaveLength(2); + expect(positions).toContainEqual({ x: 2, y: 2 }); + expect(positions).toContainEqual({ x: 16, y: 15 }); + }); +}); diff --git a/ui/mobile/src/__tests__/services/api.service.test.ts b/ui/mobile/src/__tests__/services/api.service.test.ts index b03a0991e..ece2fd2aa 100644 --- a/ui/mobile/src/__tests__/services/api.service.test.ts +++ b/ui/mobile/src/__tests__/services/api.service.test.ts @@ -90,7 +90,7 @@ describe('ApiService', () => { it('returns response data on success', async () => { apiService.setBaseUrl('http://localhost:3000'); mockRequest.mockResolvedValueOnce({ data: { status: 'ok' } }); - const result = await apiService.get('/api/v1/pose/status'); + const result = await apiService.get('/api/v1/status'); expect(result).toEqual({ status: 'ok' }); }); @@ -113,7 +113,7 @@ describe('ApiService', () => { expect(mockRequest).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', - url: 'http://draft.example:8080/api/v1/pose/status', + url: 'http://draft.example:8080/api/v1/status', }), ); }); @@ -124,7 +124,7 @@ describe('ApiService', () => { await apiService.getStatusAt('wss://draft.example'); expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ url: 'https://draft.example/api/v1/pose/status' }), + expect.objectContaining({ url: 'https://draft.example/api/v1/status' }), ); }); }); diff --git a/ui/mobile/src/__tests__/services/ws.service.test.ts b/ui/mobile/src/__tests__/services/ws.service.test.ts index cd51350c4..2525e2667 100644 --- a/ui/mobile/src/__tests__/services/ws.service.test.ts +++ b/ui/mobile/src/__tests__/services/ws.service.test.ts @@ -69,7 +69,7 @@ describe('WsService', () => { // Test with port 3000 ws.connect('http://192.168.1.10:3000'); - expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/api/v1/stream/pose'); + expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/ws/sensing'); // Clean up, create another service ws.disconnect(); @@ -77,19 +77,19 @@ describe('WsService', () => { // Test with port 8080 ws2.connect('http://myserver.local:8080'); - expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/api/v1/stream/pose'); + expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/ws/sensing'); ws2.disconnect(); // Test HTTPS -> WSS upgrade (port 443 is default for HTTPS so host drops it) const ws3 = createWsService(); ws3.connect('https://secure.example.com:443'); - expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/api/v1/stream/pose'); + expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing'); ws3.disconnect(); // Test WSS input const ws4 = createWsService(); ws4.connect('wss://secure.example.com'); - expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/api/v1/stream/pose'); + expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing'); ws4.disconnect(); // Verify port 3001 is NOT hardcoded anywhere @@ -102,6 +102,20 @@ describe('WsService', () => { }); }); + describe('timestamp normalization', () => { + it('converts Rust Unix seconds to React Native milliseconds', () => { + const { normalizeFrameTimestamp } = require('@/services/ws.service'); + const frame = { timestamp: 1_787_587_655, nodes: [], features: {}, classification: {}, signal_field: {} }; + expect(normalizeFrameTimestamp(frame).timestamp).toBe(1_787_587_655_000); + }); + + it('leaves millisecond timestamps unchanged', () => { + const { normalizeFrameTimestamp } = require('@/services/ws.service'); + const frame = { timestamp: 1_787_587_655_000, nodes: [], features: {}, classification: {}, signal_field: {} }; + expect(normalizeFrameTimestamp(frame)).toBe(frame); + }); + }); + describe('connect with empty URL', () => { it('falls back to simulation mode when URL is empty', () => { const ws = createWsService(); @@ -114,7 +128,7 @@ describe('WsService', () => { describe('connection fallback', () => { it('uses simulation when an active server connection closes normally', () => { const OrigWebSocket = globalThis.WebSocket; - let socket: any; + const sockets: any[] = []; class MockWebSocket { static OPEN = 1; @@ -126,7 +140,7 @@ describe('WsService', () => { onmessage: (() => void) | null = null; close() {} constructor() { - socket = this; + sockets.push(this); } } @@ -134,7 +148,7 @@ describe('WsService', () => { try { const ws = createWsService(); ws.connect('http://localhost:3000'); - socket.onclose?.({ code: 1000 }); + sockets[0].onclose?.({ code: 1000 }); expect(ws.getStatus()).toBe('simulated'); ws.disconnect(); @@ -178,6 +192,48 @@ describe('WsService', () => { }); }); + describe('hardware presentation rate', () => { + it('drops burst frames above 4 fps so native screens stay current', () => { + const OrigWebSocket = globalThis.WebSocket; + const sockets: any[] = []; + + class MockWebSocket { + static OPEN = 1; + static CONNECTING = 0; + readyState = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + close() {} + constructor() { sockets.push(this); } + } + + globalThis.WebSocket = MockWebSocket as any; + try { + const ws = createWsService(); + const listener = jest.fn(); + ws.subscribe(listener); + ws.connect('http://localhost:3000'); + sockets[0].onopen?.(); + const frame = JSON.stringify({ + timestamp: Date.now(), nodes: [], features: {}, classification: {}, signal_field: {}, + }); + + sockets[0].onmessage?.({ data: frame }); + sockets[0].onmessage?.({ data: frame }); + expect(listener).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(250); + sockets[0].onmessage?.({ data: frame }); + expect(listener).toHaveBeenCalledTimes(2); + ws.disconnect(); + } finally { + globalThis.WebSocket = OrigWebSocket; + } + }); + }); + describe('subscribe and unsubscribe', () => { it('adds a listener and returns an unsubscribe function', () => { const ws = createWsService(); diff --git a/ui/mobile/src/__tests__/stores/matStore.test.ts b/ui/mobile/src/__tests__/stores/matStore.test.ts index 5701db778..505359b97 100644 --- a/ui/mobile/src/__tests__/stores/matStore.test.ts +++ b/ui/mobile/src/__tests__/stores/matStore.test.ts @@ -67,6 +67,19 @@ describe('useMatStore', () => { }); }); + it('reconciles the authoritative survivor list from a hardware frame', () => { + const survivor = { + id: 'hardware-1', zone_id: 'zone-a', x: 1, y: 2, depth: -1, + triage_status: 2, triage_color: '#0f0', confidence: 0.8, + breathing_rate: 12, heart_rate: 72, first_detected: 'now', + last_updated: 'now', is_deteriorating: false, + }; + useMatStore.getState().setSurvivors([survivor]); + expect(useMatStore.getState().survivors).toEqual([survivor]); + useMatStore.getState().setSurvivors([]); + expect(useMatStore.getState().survivors).toEqual([]); + }); + describe('initial state', () => { it('has empty events array', () => { expect(useMatStore.getState().events).toEqual([]); diff --git a/ui/mobile/src/assets/webview/mat-dashboard.html b/ui/mobile/src/assets/webview/mat-dashboard.html index eafd444e4..e80f3c9c3 100644 --- a/ui/mobile/src/assets/webview/mat-dashboard.html +++ b/ui/mobile/src/assets/webview/mat-dashboard.html @@ -319,6 +319,25 @@ } processFrame(frame) { + const estimated = Number.isFinite(frame?.estimated_persons) + ? Math.max(0, Math.min(16, Math.floor(frame.estimated_persons))) + : frame?.classification?.presence ? 1 : 0; + + while (this.survivors.size > estimated) { + const lastId = [...this.survivors.keys()].pop(); + if (lastId) this.survivors.delete(lastId); + } + while (this.survivors.size < estimated) { + this.processSurvivorDetection( + null, + Number(frame?.classification?.confidence || 0), + { + breathing_rate: frame?.vital_signs?.breathing_rate_bpm, + heart_rate: frame?.vital_signs?.heart_rate_bpm, + }, + ); + } + const motion = Number(frame?.features?.motion_band_power || 0); const xDelta = isNumber(motion) ? (motion - 0.1) * 4 : 0; const yDelta = isNumber(frame?.features?.breathing_band_power || 0) @@ -333,6 +352,11 @@ survivor.y = Math.max(5, Math.min(280, survivor.y + this.motionVector.y + jitterY)); survivor.last_updated = new Date().toISOString(); } + + this.postMessage({ + type: 'SURVIVORS_SYNC', + payload: [...this.survivors.values()], + }); } renderZones(ctx) { @@ -445,9 +469,6 @@ dashboard.createEvent('earthquake', 37.7749, -122.4194, 'Training Scenario'); dashboard.addRectangleZone('Zone A', 60, 45, 170, 120); dashboard.addCircleZone('Zone B', 300, 170, 70); - dashboard.processSurvivorDetection('Zone A', 0.94, { breathing_rate: 11, hr: 128 }); - dashboard.processSurvivorDetection('Zone A', 0.88, { breathing_rate: 16, hr: 118 }); - dashboard.processSurvivorDetection('Zone B', 0.71, { breathing_rate: 9, hr: 142 }); status.textContent = 'MAT dashboard ready'; dashboard.postMessage({ type: 'READY' }); }; diff --git a/ui/mobile/src/constants/api.ts b/ui/mobile/src/constants/api.ts index 4d6c07d43..4f5289aeb 100644 --- a/ui/mobile/src/constants/api.ts +++ b/ui/mobile/src/constants/api.ts @@ -1,6 +1,6 @@ export const API_ROOT = '/api/v1'; -export const API_POSE_STATUS_PATH = '/api/v1/pose/status'; +export const API_POSE_STATUS_PATH = '/api/v1/status'; export const API_POSE_FRAMES_PATH = '/api/v1/pose/frames'; export const API_POSE_ZONES_PATH = '/api/v1/pose/zones'; export const API_POSE_CURRENT_PATH = '/api/v1/pose/current'; diff --git a/ui/mobile/src/constants/websocket.ts b/ui/mobile/src/constants/websocket.ts index 07d6e0fb7..fa6d2253a 100644 --- a/ui/mobile/src/constants/websocket.ts +++ b/ui/mobile/src/constants/websocket.ts @@ -1,3 +1,3 @@ -export const WS_PATH = '/api/v1/stream/pose'; +export const WS_PATH = '/ws/sensing'; export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]; export const MAX_RECONNECT_ATTEMPTS = 10; diff --git a/ui/mobile/src/navigation/MainTabs.tsx b/ui/mobile/src/navigation/MainTabs.tsx index f4fa6fefc..472ef402b 100644 --- a/ui/mobile/src/navigation/MainTabs.tsx +++ b/ui/mobile/src/navigation/MainTabs.tsx @@ -1,76 +1,18 @@ -import React, { Suspense } from 'react'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ThemedText } from '../components/ThemedText'; -import { ThemedView } from '../components/ThemedView'; import { colors } from '../theme/colors'; import { useMatStore } from '../stores/matStore'; import { MainTabsParamList } from './types'; - -const createPlaceholder = (label: string) => { - const Placeholder = () => ( - - {label} screen not implemented yet - - Placeholder shell - - - ); - const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder })); - - const Wrapped = () => ( - - - - Loading {label} - - - } - > - - - ); - - return Wrapped; -}; - -const wrapLazy = ( - loader: () => Promise<{ default: React.ComponentType }>, - label: string, -) => { - const fallback = createPlaceholder(label); - return React.lazy(async () => { - try { - const module = await loader(); - if (module?.default) { - return module; - } - } catch { - // keep fallback for shell-only screens - } - return { default: fallback } as { default: React.ComponentType }; - }); -}; - -const withSuspense = (Component: React.ComponentType) => { - const SuspendedScreen = () => ( - }> - - - ); - return SuspendedScreen; -}; - -const LiveScreen = withSuspense(wrapLazy(() => import('../screens/LiveScreen'), 'Live')); -const NLOSScreen = withSuspense(wrapLazy(() => import('../screens/NLOSScreen'), 'NLOS')); -const VitalsScreen = withSuspense(wrapLazy(() => import('../screens/VitalsScreen'), 'Vitals')); -const ZonesScreen = withSuspense(wrapLazy(() => import('../screens/ZonesScreen'), 'Zones')); -const MATScreen = withSuspense(wrapLazy(() => import('../screens/MATScreen'), 'MAT')); -const SettingsScreen = withSuspense(wrapLazy(() => import('../screens/SettingsScreen'), 'Settings')); +import LiveScreen from '../screens/LiveScreen'; +import NLOSScreen from '../screens/NLOSScreen'; +import VitalsScreen from '../screens/VitalsScreen'; +import ZonesScreen from '../screens/ZonesScreen'; +import MATScreen from '../screens/MATScreen'; +import SettingsScreen from '../screens/SettingsScreen'; const toIconName = (routeName: keyof MainTabsParamList) => { switch (routeName) { diff --git a/ui/mobile/src/screens/LiveScreen/index.tsx b/ui/mobile/src/screens/LiveScreen/index.tsx index 9a0c818ab..7d20a68ec 100644 --- a/ui/mobile/src/screens/LiveScreen/index.tsx +++ b/ui/mobile/src/screens/LiveScreen/index.tsx @@ -107,7 +107,7 @@ export const LiveScreen = () => { 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 personCount = lastFrame?.estimated_persons ?? (lastFrame?.classification?.presence ? 1 : 0); const mode = getMode(connectionStatus, isSimulated, lastFrame); if (error) { diff --git a/ui/mobile/src/screens/MATScreen/index.tsx b/ui/mobile/src/screens/MATScreen/index.tsx index 7aafb3ae2..7bfee7521 100644 --- a/ui/mobile/src/screens/MATScreen/index.tsx +++ b/ui/mobile/src/screens/MATScreen/index.tsx @@ -47,6 +47,7 @@ export const MATScreen = () => { const survivors = useMatStore((state) => state.survivors); const alerts = useMatStore((state) => state.alerts); const upsertSurvivor = useMatStore((state) => state.upsertSurvivor); + const setSurvivors = useMatStore((state) => state.setSurvivors); const addAlert = useMatStore((state) => state.addAlert); const upsertEvent = useMatStore((state) => state.upsertEvent); const dataSource = useMatStore((state) => state.dataSource); @@ -65,6 +66,9 @@ export const MATScreen = () => { upsertSurvivor(survivor); } }, + onSurvivorsSync: (nextSurvivors) => { + setSurvivors(nextSurvivors.filter(isSurvivor)); + }, onAlertGenerated: (alert) => { if (isAlert(alert)) { addAlert(alert); diff --git a/ui/mobile/src/screens/MATScreen/useMatBridge.ts b/ui/mobile/src/screens/MATScreen/useMatBridge.ts index b502f4219..57b2f2be7 100644 --- a/ui/mobile/src/screens/MATScreen/useMatBridge.ts +++ b/ui/mobile/src/screens/MATScreen/useMatBridge.ts @@ -5,7 +5,7 @@ import type { SensingFrame } from '@/types/sensing'; type MatBridgeMessageType = 'CREATE_EVENT' | 'ADD_ZONE' | 'FRAME_UPDATE'; -type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'ALERT_GENERATED'; +type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'SURVIVORS_SYNC' | 'ALERT_GENERATED'; type MatIncomingMessage = { type: MatIncomingType; @@ -19,6 +19,7 @@ type MatOutgoingMessage = { type UseMatBridgeOptions = { onSurvivorDetected?: (survivor: Survivor) => void; + onSurvivorsSync?: (survivors: Survivor[]) => void; onAlertGenerated?: (alert: Alert) => void; }; @@ -30,7 +31,7 @@ const safeParseJson = (value: string): unknown | null => { } }; -export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBridgeOptions = {}) => { +export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected, onSurvivorsSync }: UseMatBridgeOptions = {}) => { const webViewRef = useRef(null); const isReadyRef = useRef(false); const queuedMessages = useRef([]); @@ -100,11 +101,18 @@ export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBri return; } + if (message.type === 'SURVIVORS_SYNC') { + if (Array.isArray(message.payload)) { + onSurvivorsSync?.(message.payload as Survivor[]); + } + return; + } + if (message.type === 'ALERT_GENERATED') { onAlertGenerated?.(message.payload as Alert); } }, - [flush, onAlertGenerated, onSurvivorDetected], + [flush, onAlertGenerated, onSurvivorDetected, onSurvivorsSync], ); return { diff --git a/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx b/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx index 5795a7602..0bc70e742 100644 --- a/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx +++ b/ui/mobile/src/screens/VitalsScreen/BreathingGauge.tsx @@ -26,7 +26,10 @@ const deriveBreathingValue = ( export const BreathingGauge = () => { const breathingBand = usePoseStore((state) => state.features?.breathing_band_power); - const breathingBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.breathing_bpm); + const breathingBpm = usePoseStore((state) => { + const vitals = state.lastFrame?.vital_signs; + return vitals?.breathing_bpm ?? vitals?.breathing_rate_bpm; + }); const value = useMemo( () => deriveBreathingValue(breathingBand, breathingBpm), diff --git a/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx b/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx index c1060665a..6afaa9353 100644 --- a/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx +++ b/ui/mobile/src/screens/VitalsScreen/HeartRateGauge.tsx @@ -29,7 +29,10 @@ const deriveHeartRate = ( }; export const HeartRateGauge = () => { - const heartProxyBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.hr_proxy_bpm); + const heartProxyBpm = usePoseStore((state) => { + const vitals = state.lastFrame?.vital_signs; + return vitals?.hr_proxy_bpm ?? vitals?.heart_rate_bpm; + }); const motionBand = usePoseStore((state) => state.features?.motion_band_power); const breathingBand = usePoseStore((state) => state.features?.breathing_band_power); diff --git a/ui/mobile/src/screens/VitalsScreen/index.tsx b/ui/mobile/src/screens/VitalsScreen/index.tsx index 55bdf3ce2..c6a4390aa 100644 --- a/ui/mobile/src/screens/VitalsScreen/index.tsx +++ b/ui/mobile/src/screens/VitalsScreen/index.tsx @@ -49,7 +49,7 @@ export default function VitalsScreen() { })); const classificationColor = - classification?.motion_level === 'active' + classification?.motion_level === 'active' || classification?.motion_level === 'present_moving' ? colors.success : classification?.motion_level === 'present_still' ? colors.warn diff --git a/ui/mobile/src/screens/ZonesScreen/index.tsx b/ui/mobile/src/screens/ZonesScreen/index.tsx index 9565d1e20..82166ca55 100644 --- a/ui/mobile/src/screens/ZonesScreen/index.tsx +++ b/ui/mobile/src/screens/ZonesScreen/index.tsx @@ -34,7 +34,8 @@ export const ZonesScreen = () => { const lastFrame = usePoseStore((state) => state.lastFrame); const signalField = usePoseStore((state) => state.signalField); - const { gridValues, personPositions } = useOccupancyGrid(signalField); + const estimatedPersonCount = lastFrame?.estimated_persons ?? (lastFrame?.classification?.presence ? 1 : 0); + const { gridValues, personPositions } = useOccupancyGrid(signalField, estimatedPersonCount); const { width } = useWindowDimensions(); const mapSize = useMemo(() => Math.max(240, Math.min(width - spacing.md * 2, 520)), [width]); diff --git a/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts b/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts index 4601bdd7a..2e8273278 100644 --- a/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts +++ b/ui/mobile/src/screens/ZonesScreen/useOccupancyGrid.ts @@ -66,7 +66,36 @@ const readClassificationPositions = (classification: Classification | undefined) ); }; -export const useOccupancyGrid = (signalField: SignalField | null): { gridValues: number[]; personPositions: Point[] } => { +export const deriveFieldPositions = (values: number[], requestedCount: number): Point[] => { + const count = Math.max(0, Math.min(16, Math.floor(requestedCount))); + if (count === 0 || values.length === 0) return []; + + const candidates = values + .slice(0, CELL_COUNT) + .map((value, index) => ({ + x: index % GRID_SIZE, + y: Math.floor(index / GRID_SIZE), + value: parseNumber(value) ?? 0, + })) + .sort((a, b) => b.value - a.value); + + const positions: Point[] = []; + for (const candidate of candidates) { + const separated = positions.every(({ x, y }) => { + const dx = candidate.x - x; + const dy = candidate.y - y; + return dx * dx + dy * dy >= 9; + }); + if (separated) positions.push({ x: candidate.x, y: candidate.y }); + if (positions.length === count) break; + } + return positions; +}; + +export const useOccupancyGrid = ( + signalField: SignalField | null, + estimatedPersonCount = 0, +): { gridValues: number[]; personPositions: Point[] } => { const classification = usePoseStore((state) => state.classification) as Classification | undefined; const gridValues = useMemo(() => { @@ -99,8 +128,8 @@ export const useOccupancyGrid = (signalField: SignalField | null): { gridValues: .slice(0, 16); } - return [] as Point[]; - }, [classification]); + return deriveFieldPositions(gridValues, estimatedPersonCount); + }, [classification, estimatedPersonCount, gridValues]); return { gridValues, diff --git a/ui/mobile/src/services/ws.service.ts b/ui/mobile/src/services/ws.service.ts index b42448328..a1602b2e5 100644 --- a/ui/mobile/src/services/ws.service.ts +++ b/ui/mobile/src/services/ws.service.ts @@ -5,6 +5,22 @@ import { generateSimulatedData } from '@/services/simulation.service'; import type { ConnectionStatus, SensingFrame } from '@/types/sensing'; type FrameListener = (frame: SensingFrame) => void; +const PRESENTATION_FRAME_INTERVAL_MS = 250; + +export const normalizeFrameTimestamp = (frame: SensingFrame): SensingFrame => { + const timestamp = frame.timestamp; + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { + return frame; + } + + // The Rust sensing server emits Unix seconds while simulation and React + // Native use Unix milliseconds. Keep one unit inside the mobile app. + if (timestamp > 0 && timestamp < 100_000_000_000) { + return { ...frame, timestamp: timestamp * 1000 }; + } + + return frame; +}; class WsService { private ws: WebSocket | null = null; @@ -15,6 +31,7 @@ class WsService { private targetUrl = ''; private active = false; private status: ConnectionStatus = 'disconnected'; + private lastFrameDeliveryAt = 0; connect(url: string): void { const wasActive = this.active; @@ -55,6 +72,7 @@ class WsService { socket.onopen = () => { this.reconnectAttempt = 0; + this.lastFrameDeliveryAt = 0; this.stopSimulation(); this.handleStatusChange('connected'); }; @@ -62,7 +80,12 @@ class WsService { socket.onmessage = (evt) => { try { const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data); - const frame = JSON.parse(raw) as SensingFrame; + const frame = normalizeFrameTimestamp(JSON.parse(raw) as SensingFrame); + const now = Date.now(); + if (now - this.lastFrameDeliveryAt < PRESENTATION_FRAME_INTERVAL_MS) { + return; + } + this.lastFrameDeliveryAt = now; this.listeners.forEach((listener) => listener(frame)); } catch { // ignore malformed frames @@ -73,7 +96,7 @@ class WsService { // handled by onclose }; - socket.onclose = (evt) => { + socket.onclose = () => { this.ws = null; if (!this.active) { this.handleStatusChange('disconnected'); diff --git a/ui/mobile/src/stores/matStore.ts b/ui/mobile/src/stores/matStore.ts index 64bfbfddd..167983d0c 100644 --- a/ui/mobile/src/stores/matStore.ts +++ b/ui/mobile/src/stores/matStore.ts @@ -14,6 +14,7 @@ export interface MatState { upsertEvent: (event: DisasterEvent) => void; addZone: (zone: ScanZone) => void; upsertSurvivor: (survivor: Survivor) => void; + setSurvivors: (survivors: Survivor[]) => void; addAlert: (alert: Alert) => void; setSelectedEvent: (id: string | null) => void; setDataSource: (source: 'real' | 'simulated') => void; @@ -65,6 +66,10 @@ export const useMatStore = create((set) => ({ }); }, + setSurvivors: (survivors) => { + set({ survivors }); + }, + addAlert: (alert) => { set((state) => { if (state.alerts.some((item) => item.id === alert.id)) { diff --git a/ui/mobile/src/types/sensing.ts b/ui/mobile/src/types/sensing.ts index 822623b5c..ee3c3bed7 100644 --- a/ui/mobile/src/types/sensing.ts +++ b/ui/mobile/src/types/sensing.ts @@ -21,7 +21,7 @@ export interface FeatureSet { } export interface Classification { - motion_level: 'absent' | 'present_still' | 'active'; + motion_level: 'absent' | 'present_still' | 'present_moving' | 'active'; presence: boolean; confidence: number; } @@ -54,6 +54,7 @@ export interface PersonDetection { id?: number; confidence: number; keypoints: PoseKeypoint[]; + position?: [number, number, number]; } export interface SensingFrame {