fix(mobile): complete ESP32 hardware data path

This commit is contained in:
ruv committed 2026-08-24 12:26:07 -04:00
1 parent a11d9a4aa0
commit 61718bdcb2
19 files changed
+223 -95

No files matched your search

@@ -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 });
});
});
@@ -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' }),
);
});
});
@@ -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();
@@ -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([]);
@@ -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' });
};
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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;
+8 -66
View File
@@ -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 = () => (
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ThemedText preset="bodyLg">{label} screen not implemented yet</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">
Placeholder shell
</ThemedText>
</ThemedView>
);
const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder }));
const Wrapped = () => (
<Suspense
fallback={
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.accent} />
<ThemedText preset="bodySm" color="textSecondary" style={{ marginTop: 8 }}>
Loading {label}
</ThemedText>
</ThemedView>
}
>
<LazyPlaceholder />
</Suspense>
);
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 = () => (
<Suspense fallback={<ActivityIndicator color={colors.accent} />}>
<Component />
</Suspense>
);
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) {
+1 -1
View File
@@ -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) {
@@ -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);
@@ -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<WebView | null>(null);
const isReadyRef = useRef(false);
const queuedMessages = useRef<string[]>([]);
@@ -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 {
@@ -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),
@@ -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);
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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]);
@@ -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,
+25 -2
View File
@@ -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');
+5
View File
@@ -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<MatState>((set) => ({
});
},
setSurvivors: (survivors) => {
set({ survivors });
},
addAlert: (alert) => {
set((state) => {
if (state.alerts.some((item) => item.id === alert.id)) {
+2 -1
View File
@@ -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 {