mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-23 07:02:44 -05:00
fix(mobile): complete ESP32 hardware data path
This commit is contained in:
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,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,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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user