mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-31 12:36:09 +00:00
feat(nlos): add consumer transient sensing pipeline
This commit is contained in:
50
ui/mobile/src/__tests__/__mocks__/reanimated.js
Normal file
50
ui/mobile/src/__tests__/__mocks__/reanimated.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const ReactNative = require('react-native');
|
||||
|
||||
const identity = (value) => value;
|
||||
const noop = () => undefined;
|
||||
const createSharedValue = (initialValue) => ({
|
||||
value: initialValue,
|
||||
get: () => initialValue,
|
||||
set(nextValue) {
|
||||
this.value = typeof nextValue === 'function' ? nextValue(this.value) : nextValue;
|
||||
},
|
||||
});
|
||||
const evaluate = (updater) => updater();
|
||||
const createAnimatedComponent = (Component) => Component;
|
||||
|
||||
const Easing = {
|
||||
linear: identity,
|
||||
ease: identity,
|
||||
quad: identity,
|
||||
cubic: identity,
|
||||
in: identity,
|
||||
out: identity,
|
||||
inOut: identity,
|
||||
};
|
||||
|
||||
const Animated = {
|
||||
View: ReactNative.View,
|
||||
Text: ReactNative.Text,
|
||||
Image: ReactNative.Image,
|
||||
ScrollView: ReactNative.ScrollView,
|
||||
createAnimatedComponent,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
__esModule: true,
|
||||
default: Animated,
|
||||
Easing,
|
||||
cancelAnimation: noop,
|
||||
createAnimatedComponent,
|
||||
interpolateColor: (_value, _input, output) => output[0],
|
||||
runOnJS: identity,
|
||||
useAnimatedProps: evaluate,
|
||||
useAnimatedReaction: noop,
|
||||
useAnimatedStyle: evaluate,
|
||||
useDerivedValue: (updater) => createSharedValue(updater()),
|
||||
useSharedValue: createSharedValue,
|
||||
withRepeat: identity,
|
||||
withSequence: (...values) => values[values.length - 1],
|
||||
withSpring: identity,
|
||||
withTiming: identity,
|
||||
};
|
||||
@@ -68,13 +68,13 @@ describe('MATScreen', () => {
|
||||
|
||||
it('renders the connection banner', () => {
|
||||
const { MATScreen } = require('@/screens/MATScreen');
|
||||
const { getByText } = render(
|
||||
const { getAllByText } = render(
|
||||
<ThemeProvider>
|
||||
<MATScreen />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// Simulated status maps to 'simulated' banner -> "SIMULATED DATA"
|
||||
expect(getByText('SIMULATED DATA')).toBeTruthy();
|
||||
expect(getAllByText('SIMULATED DATA').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows simulation warning overlay when simulated and not acknowledged', () => {
|
||||
|
||||
133
ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx
Normal file
133
ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import { createSyntheticNlosFrame } from '@/services/nlos.service';
|
||||
import { createLiveNlosFrameFixture } from '@/testUtils/nlosFixtures';
|
||||
import { ThemeProvider } from '@/theme/ThemeContext';
|
||||
|
||||
const syntheticFrame = createSyntheticNlosFrame(0, 1_700_000_000_000);
|
||||
const mockNlosResult: Record<string, any> = {
|
||||
frame: syntheticFrame,
|
||||
freshness: 'fresh' as const,
|
||||
streamStatus: 'synthetic_replay' as const,
|
||||
lastRejectedReason: null,
|
||||
rejectedFrameCount: 0,
|
||||
liveCredentialAvailable: false,
|
||||
configureCredential: jest.fn(() => true),
|
||||
forgetCredential: jest.fn(),
|
||||
startReplay: jest.fn(),
|
||||
connectLive: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@/hooks/useNlosStream', () => ({
|
||||
useNlosStream: () => mockNlosResult,
|
||||
}));
|
||||
|
||||
jest.mock('react-native-svg', () => {
|
||||
const { View, Text } = require('react-native');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: View,
|
||||
Circle: View,
|
||||
Ellipse: View,
|
||||
Line: View,
|
||||
Polygon: View,
|
||||
Rect: View,
|
||||
Text,
|
||||
};
|
||||
});
|
||||
|
||||
describe('NLOSScreen', () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(mockNlosResult, {
|
||||
frame: syntheticFrame,
|
||||
freshness: 'fresh',
|
||||
streamStatus: 'synthetic_replay',
|
||||
lastRejectedReason: null,
|
||||
rejectedFrameCount: 0,
|
||||
liveCredentialAvailable: false,
|
||||
});
|
||||
mockNlosResult.configureCredential.mockClear();
|
||||
mockNlosResult.forgetCredential.mockClear();
|
||||
});
|
||||
|
||||
it('renders the RuView NLOS screen and iPhone API boundary', () => {
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
expect(screen.getByText('RuView NLOS')).toBeTruthy();
|
||||
expect(screen.getByText(/does not access raw iPhone LiDAR timing data/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('always watermarks synthetic replay', () => {
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
expect(screen.getByTestId('nlos-synthetic-watermark')).toBeTruthy();
|
||||
expect(screen.getByTestId('nlos-provenance-badge').props.children).toBe('SYNTHETIC');
|
||||
});
|
||||
|
||||
it('does not enable live without an ephemeral credential', () => {
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
const button = screen.getByRole('button', { name: 'CONNECT AUTHENTICATED LIVE' });
|
||||
expect(button.props.accessibilityState?.disabled ?? button.props.disabled).toBeTruthy();
|
||||
expect(screen.getByText(/never stored by this client/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps a manually entered pairing credential bounded and masked', () => {
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
|
||||
const input = screen.getByTestId('nlos-credential-input');
|
||||
expect(input.props.secureTextEntry).toBe(true);
|
||||
expect(input.props.maxLength).toBe(512);
|
||||
const unlock = screen.getByRole('button', { name: 'UNLOCK AUTHENTICATED LIVE' });
|
||||
expect(unlock.props.accessibilityState?.disabled ?? unlock.props.disabled).toBeTruthy();
|
||||
|
||||
const token = 'p'.repeat(32);
|
||||
fireEvent.changeText(input, token);
|
||||
fireEvent.press(screen.getByRole('button', { name: 'UNLOCK AUTHENTICATED LIVE' }));
|
||||
expect(mockNlosResult.configureCredential).toHaveBeenCalledWith(token);
|
||||
expect(screen.getByTestId('nlos-credential-input').props.value).toBe('');
|
||||
});
|
||||
|
||||
it('renders unknown evidence without a live or synthetic claim', () => {
|
||||
Object.assign(mockNlosResult, { frame: null, freshness: 'unknown', streamStatus: 'idle' });
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
expect(screen.getByTestId('nlos-provenance-badge').props.children).toBe('UNKNOWN');
|
||||
expect(screen.queryByTestId('nlos-synthetic-watermark')).toBeNull();
|
||||
expect(screen.getByText(/Unknown evidence is never promoted to live/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps stale measured frames visibly stale', () => {
|
||||
Object.assign(mockNlosResult, {
|
||||
frame: createLiveNlosFrameFixture(),
|
||||
freshness: 'stale',
|
||||
streamStatus: 'error',
|
||||
liveCredentialAvailable: true,
|
||||
});
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
expect(screen.getByTestId('nlos-stale-overlay')).toBeTruthy();
|
||||
expect(screen.getByTestId('nlos-freshness-badge').props.children).toBe('STALE');
|
||||
expect(screen.getByTestId('nlos-track-count').props.children).toBe(0);
|
||||
expect(screen.getByTestId('nlos-mean-confidence').props.children).toBe('N/A');
|
||||
expect(screen.queryByTestId('nlos-synthetic-watermark')).toBeNull();
|
||||
});
|
||||
|
||||
it('never draws or counts unknown target hypotheses', () => {
|
||||
const live = createLiveNlosFrameFixture();
|
||||
Object.assign(mockNlosResult, {
|
||||
frame: {
|
||||
...live,
|
||||
tracks: [{ ...live.tracks[0], state: 'unknown' }],
|
||||
},
|
||||
freshness: 'fresh',
|
||||
streamStatus: 'live',
|
||||
});
|
||||
const { NLOSScreen } = require('@/screens/NLOSScreen');
|
||||
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
|
||||
expect(screen.getByTestId('nlos-track-count').props.children).toBe(0);
|
||||
expect(screen.getByTestId('nlos-mean-confidence').props.children).toBe('N/A');
|
||||
expect(screen.queryByText(live.tracks[0].trackId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ describe('SettingsScreen', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
serverUrl: 'http://localhost:3000',
|
||||
nlosServerUrl: 'http://localhost:3000',
|
||||
rssiScanEnabled: false,
|
||||
theme: 'system',
|
||||
alertSoundEnabled: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
import { ThemeProvider } from '@/theme/ThemeContext';
|
||||
import { usePoseStore } from '@/stores/poseStore';
|
||||
|
||||
jest.mock('@/hooks/usePoseStream', () => ({
|
||||
usePoseStream: () => ({
|
||||
@@ -26,6 +27,10 @@ jest.mock('react-native-svg', () => {
|
||||
});
|
||||
|
||||
describe('VitalsScreen', () => {
|
||||
beforeEach(() => {
|
||||
usePoseStore.setState({ connectionStatus: 'simulated', isSimulated: true });
|
||||
});
|
||||
|
||||
it('module exports VitalsScreen as default', () => {
|
||||
const mod = require('@/screens/VitalsScreen');
|
||||
expect(mod.default).toBeDefined();
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('ApiService', () => {
|
||||
isAxiosError: true,
|
||||
};
|
||||
mockRequest.mockRejectedValue(axiosError);
|
||||
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(true);
|
||||
(mockAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(true);
|
||||
|
||||
await expect(apiService.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -142,7 +142,7 @@ describe('ApiService', () => {
|
||||
|
||||
it('normalizes generic Error', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('network timeout'));
|
||||
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
|
||||
(mockAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(apiService.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({ message: 'network timeout' }),
|
||||
@@ -151,7 +151,7 @@ describe('ApiService', () => {
|
||||
|
||||
it('normalizes unknown error', async () => {
|
||||
mockRequest.mockRejectedValue('string error');
|
||||
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
|
||||
(mockAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(apiService.get('/test')).rejects.toEqual(
|
||||
expect.objectContaining({ message: 'Unknown error' }),
|
||||
@@ -163,7 +163,7 @@ describe('ApiService', () => {
|
||||
it('retries up to 2 times on failure then throws', async () => {
|
||||
const error = new Error('fail');
|
||||
mockRequest.mockRejectedValue(error);
|
||||
(mockAxios.isAxiosError as jest.Mock).mockReturnValue(false);
|
||||
(mockAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(apiService.get('/flaky')).rejects.toEqual(
|
||||
expect.objectContaining({ message: 'fail' }),
|
||||
|
||||
285
ui/mobile/src/__tests__/services/nlos.service.test.ts
Normal file
285
ui/mobile/src/__tests__/services/nlos.service.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
NLOS_AUTHENTICATED_SCHEMA,
|
||||
NLOS_TICKET_SCHEMA,
|
||||
NlosService,
|
||||
configureNlosBearerToken,
|
||||
createSyntheticNlosFrame,
|
||||
hasConfiguredNlosBearerToken,
|
||||
type NlosServiceDependencies,
|
||||
} from '@/services/nlos.service';
|
||||
import { createLiveNlosFrameFixture } from '@/testUtils/nlosFixtures';
|
||||
import { NLOS_MAX_MESSAGE_BYTES } from '@/types/nlos';
|
||||
|
||||
class MockSocket {
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: ((event: { code: number }) => void) | null = null;
|
||||
close = jest.fn();
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_100;
|
||||
const BEARER_TOKEN = 'e'.repeat(32);
|
||||
|
||||
const createHarness = () => {
|
||||
const socket = new MockSocket();
|
||||
const fetchMock = jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
schema: NLOS_TICKET_SCHEMA,
|
||||
webSocketUrl: `wss://ruview.example/api/v1/nlos/ws?ticket=${'a'.repeat(64)}`,
|
||||
expiresAtUnixMs: NOW + 30_000,
|
||||
}),
|
||||
}));
|
||||
const dependencies: NlosServiceDependencies = {
|
||||
fetch: fetchMock,
|
||||
createWebSocket: jest.fn(() => socket),
|
||||
now: jest.fn(() => NOW),
|
||||
setInterval: globalThis.setInterval.bind(globalThis),
|
||||
clearInterval: globalThis.clearInterval.bind(globalThis),
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
};
|
||||
return { service: new NlosService(dependencies), socket, fetchMock, dependencies };
|
||||
};
|
||||
|
||||
const authenticate = (socket: MockSocket) => {
|
||||
socket.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
schema: NLOS_AUTHENTICATED_SCHEMA,
|
||||
sessionId: 'live-session-1',
|
||||
expiresAtUnixMs: NOW + 25_000,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
describe('NlosService', () => {
|
||||
afterEach(() => {
|
||||
configureNlosBearerToken(null);
|
||||
});
|
||||
|
||||
it('exchanges a transport Bearer token for a one time socket before accepting live frames', async () => {
|
||||
const { service, socket, fetchMock } = createHarness();
|
||||
const listener = jest.fn();
|
||||
service.subscribe(listener);
|
||||
|
||||
await expect(service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN })).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://ruview.example/api/v1/nlos/ws-ticket',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: `Bearer ${BEARER_TOKEN}` }),
|
||||
}),
|
||||
);
|
||||
|
||||
authenticate(socket);
|
||||
expect(service.getStatus()).toBe('live');
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
socket.onmessage?.({ data: JSON.stringify(frame) });
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
frame,
|
||||
channel: 'authenticated_stream',
|
||||
receivedAtUnixMs: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects data before the authenticated session acknowledgement', async () => {
|
||||
const { service, socket } = createHarness();
|
||||
const rejected = jest.fn();
|
||||
service.subscribeRejected(rejected);
|
||||
await service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN });
|
||||
|
||||
socket.onmessage?.({ data: JSON.stringify(createLiveNlosFrameFixture()) });
|
||||
expect(rejected).toHaveBeenCalledWith('unauthenticated');
|
||||
expect(socket.close).toHaveBeenCalledWith(1008, 'authentication required');
|
||||
});
|
||||
|
||||
it('accepts authenticated synthetic server frames without promoting their evidence', async () => {
|
||||
const { service, socket } = createHarness();
|
||||
const listener = jest.fn();
|
||||
service.subscribe(listener);
|
||||
await service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN });
|
||||
authenticate(socket);
|
||||
const frame = {
|
||||
...createSyntheticNlosFrame(3, NOW),
|
||||
sessionId: 'live-session-1',
|
||||
provenance: {
|
||||
...createSyntheticNlosFrame(3, NOW).provenance,
|
||||
histogramPreserved: true,
|
||||
},
|
||||
};
|
||||
socket.onmessage?.({ data: JSON.stringify(frame) });
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
frame,
|
||||
channel: 'authenticated_stream',
|
||||
receivedAtUnixMs: NOW,
|
||||
});
|
||||
expect(frame.evidenceLevel).toBe('l0_synthetic');
|
||||
});
|
||||
|
||||
it('bounds the authenticated socket handshake to five seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service, socket } = createHarness();
|
||||
const rejected = jest.fn();
|
||||
service.subscribeRejected(rejected);
|
||||
await service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN });
|
||||
jest.advanceTimersByTime(5_000);
|
||||
expect(rejected).toHaveBeenCalledWith('unauthenticated');
|
||||
expect(socket.close).toHaveBeenCalledWith(1008, 'authentication timeout');
|
||||
expect(service.getStatus()).toBe('error');
|
||||
service.disconnect();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('expires an authenticated session even when the socket is idle', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service, socket } = createHarness();
|
||||
const rejected = jest.fn();
|
||||
service.subscribeRejected(rejected);
|
||||
await service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN });
|
||||
authenticate(socket);
|
||||
expect(service.getStatus()).toBe('live');
|
||||
jest.advanceTimersByTime(25_000);
|
||||
expect(rejected).toHaveBeenCalledWith('unauthenticated');
|
||||
expect(socket.close).toHaveBeenCalledWith(1008, 'session expired');
|
||||
expect(service.getStatus()).toBe('error');
|
||||
service.disconnect();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects duplicate and out of order sequences', async () => {
|
||||
const { service, socket } = createHarness();
|
||||
const listener = jest.fn();
|
||||
const rejected = jest.fn();
|
||||
service.subscribe(listener);
|
||||
service.subscribeRejected(rejected);
|
||||
await service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN });
|
||||
authenticate(socket);
|
||||
|
||||
socket.onmessage?.({ data: JSON.stringify(createLiveNlosFrameFixture({ sequence: 5 })) });
|
||||
socket.onmessage?.({ data: JSON.stringify(createLiveNlosFrameFixture({ sequence: 5 })) });
|
||||
socket.onmessage?.({ data: JSON.stringify(createLiveNlosFrameFixture({ sequence: 4 })) });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(rejected).toHaveBeenCalledTimes(2);
|
||||
expect(rejected).toHaveBeenLastCalledWith('out_of_order');
|
||||
});
|
||||
|
||||
it('bounds messages and rejects binary payloads', async () => {
|
||||
const { service, socket } = createHarness();
|
||||
const rejected = jest.fn();
|
||||
service.subscribeRejected(rejected);
|
||||
await service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: BEARER_TOKEN });
|
||||
authenticate(socket);
|
||||
|
||||
socket.onmessage?.({ data: `{"padding":"${'x'.repeat(NLOS_MAX_MESSAGE_BYTES)}"}` });
|
||||
socket.onmessage?.({ data: new Uint8Array([1, 2, 3]) });
|
||||
expect(rejected).toHaveBeenCalledWith('message_too_large');
|
||||
expect(rejected).toHaveBeenCalledWith('unsupported_binary');
|
||||
});
|
||||
|
||||
it('rejects remote cleartext server URLs', async () => {
|
||||
const { service, fetchMock } = createHarness();
|
||||
await expect(service.connectLive({ serverUrl: 'http://ruview.example', bearerToken: BEARER_TOKEN })).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(service.getStatus()).toBe('error');
|
||||
});
|
||||
|
||||
it('rejects a ticket that redirects the socket to another authority', async () => {
|
||||
const { service, fetchMock, dependencies } = createHarness();
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
schema: NLOS_TICKET_SCHEMA,
|
||||
webSocketUrl: `wss://attacker.example/api/v1/nlos/ws?ticket=${'a'.repeat(64)}`,
|
||||
expiresAtUnixMs: NOW + 10_000,
|
||||
}),
|
||||
});
|
||||
await expect(service.connectLive({
|
||||
serverUrl: 'https://ruview.example',
|
||||
bearerToken: BEARER_TOKEN,
|
||||
})).resolves.toBe(false);
|
||||
expect(dependencies.createWebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enforces the 32 to 512 character ephemeral credential bound', async () => {
|
||||
const short = createHarness();
|
||||
await expect(short.service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: 'x'.repeat(31) })).resolves.toBe(false);
|
||||
expect(short.fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const long = createHarness();
|
||||
await expect(long.service.connectLive({ serverUrl: 'https://ruview.example', bearerToken: 'x'.repeat(513) })).resolves.toBe(false);
|
||||
expect(long.fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps configured credentials memory-only and applies the same length bound', () => {
|
||||
expect(configureNlosBearerToken('x'.repeat(31))).toBe(false);
|
||||
expect(hasConfiguredNlosBearerToken()).toBe(false);
|
||||
expect(configureNlosBearerToken(`${'x'.repeat(31)} `)).toBe(false);
|
||||
expect(configureNlosBearerToken('x'.repeat(513))).toBe(false);
|
||||
expect(hasConfiguredNlosBearerToken()).toBe(false);
|
||||
|
||||
expect(configureNlosBearerToken('x'.repeat(32))).toBe(true);
|
||||
expect(hasConfiguredNlosBearerToken()).toBe(true);
|
||||
expect(configureNlosBearerToken(null)).toBe(true);
|
||||
expect(hasConfiguredNlosBearerToken()).toBe(false);
|
||||
});
|
||||
|
||||
it('emits bounded, visibly synthetic deterministic replay frames', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service } = createHarness();
|
||||
const listener = jest.fn();
|
||||
service.subscribe(listener);
|
||||
service.startDeterministicReplay(1_000);
|
||||
expect(service.getStatus()).toBe('synthetic_replay');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][0]).toEqual({
|
||||
frame: createSyntheticNlosFrame(0, NOW, 30),
|
||||
channel: 'deterministic_replay',
|
||||
receivedAtUnixMs: NOW,
|
||||
});
|
||||
jest.advanceTimersByTime(34);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
service.disconnect();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe synthetic sequences and bounds non-finite replay rates', () => {
|
||||
expect(() => createSyntheticNlosFrame(Number.MAX_SAFE_INTEGER + 1, NOW)).toThrow(RangeError);
|
||||
expect(() => createSyntheticNlosFrame(0, Number.MAX_SAFE_INTEGER)).toThrow(RangeError);
|
||||
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const { service } = createHarness();
|
||||
const listener = jest.fn();
|
||||
service.subscribe(listener);
|
||||
service.startDeterministicReplay(Number.NaN);
|
||||
jest.advanceTimersByTime(66);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
service.disconnect();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports velocity in metres per second at the selected replay rate', () => {
|
||||
const slow = createSyntheticNlosFrame(0, NOW, 10).tracks[0].velocityMps;
|
||||
const fast = createSyntheticNlosFrame(0, NOW, 20).tracks[0].velocityMps;
|
||||
expect(fast.x).toBeCloseTo(slow.x * 2, 6);
|
||||
expect(fast.y).toBeCloseTo(slow.y * 2, 6);
|
||||
expect(fast.z).toBeCloseTo(slow.z * 2, 6);
|
||||
});
|
||||
});
|
||||
126
ui/mobile/src/__tests__/services/nlos.validation.test.ts
Normal file
126
ui/mobile/src/__tests__/services/nlos.validation.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { parseNlosTrackFrame, utf8ByteLength, validateNlosTrackFrame } from '@/services/nlos.validation';
|
||||
import { createSyntheticNlosFrame } from '@/services/nlos.service';
|
||||
import { createLiveNlosFrameFixture } from '@/testUtils/nlosFixtures';
|
||||
import { NLOS_MAX_MESSAGE_BYTES, NLOS_MAX_TRACKS } from '@/types/nlos';
|
||||
|
||||
describe('NLOS track validation', () => {
|
||||
it('accepts the canonical measured live contract', () => {
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
expect(validateNlosTrackFrame(frame)).toEqual({ ok: true, value: frame });
|
||||
expect(parseNlosTrackFrame(JSON.stringify(frame))).toEqual({ ok: true, value: frame });
|
||||
});
|
||||
|
||||
it('rejects malformed JSON and messages over the 256 KiB limit', () => {
|
||||
expect(parseNlosTrackFrame('{bad-json')).toEqual({ ok: false, reason: 'malformed_json' });
|
||||
const oversized = `{"padding":"${'x'.repeat(NLOS_MAX_MESSAGE_BYTES)}"}`;
|
||||
expect(utf8ByteLength(oversized)).toBeGreaterThan(NLOS_MAX_MESSAGE_BYTES);
|
||||
expect(parseNlosTrackFrame(oversized)).toEqual({ ok: false, reason: 'message_too_large' });
|
||||
});
|
||||
|
||||
it('rejects excessive track counts and spatial bounds', () => {
|
||||
const oneTrack = createLiveNlosFrameFixture().tracks[0];
|
||||
const tooMany = createLiveNlosFrameFixture({
|
||||
tracks: Array.from({ length: NLOS_MAX_TRACKS + 1 }, (_, index) => ({
|
||||
...oneTrack,
|
||||
trackId: `target-${index}`,
|
||||
})),
|
||||
});
|
||||
expect(validateNlosTrackFrame(tooMany)).toEqual({ ok: false, reason: 'invalid_bounds' });
|
||||
|
||||
const outOfBounds = createLiveNlosFrameFixture({
|
||||
tracks: [{ ...oneTrack, positionM: { x: 100.01, y: 0, z: 0 } }],
|
||||
});
|
||||
expect(validateNlosTrackFrame(outOfBounds)).toEqual({ ok: false, reason: 'invalid_shape' });
|
||||
});
|
||||
|
||||
it('never promotes depth only or histogram free data to live NLOS', () => {
|
||||
const frame = createLiveNlosFrameFixture({
|
||||
provenance: {
|
||||
...createLiveNlosFrameFixture().provenance,
|
||||
transientKind: 'depth_only',
|
||||
histogramPreserved: false,
|
||||
},
|
||||
});
|
||||
expect(validateNlosTrackFrame(frame)).toEqual({ ok: false, reason: 'invalid_provenance' });
|
||||
expect(validateNlosTrackFrame(createLiveNlosFrameFixture({
|
||||
provenance: {
|
||||
...createLiveNlosFrameFixture().provenance,
|
||||
transport: 'replay',
|
||||
},
|
||||
}))).toEqual({ ok: false, reason: 'invalid_provenance' });
|
||||
});
|
||||
|
||||
it('rejects captured replay evidence when timing histograms were discarded', () => {
|
||||
const live = createLiveNlosFrameFixture();
|
||||
const replay = {
|
||||
...live,
|
||||
source: 'replay' as const,
|
||||
provenance: {
|
||||
...live.provenance,
|
||||
transientKind: 'replay' as const,
|
||||
histogramPreserved: false,
|
||||
transport: 'replay' as const,
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateNlosTrackFrame(replay)).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_provenance',
|
||||
});
|
||||
expect(validateNlosTrackFrame({
|
||||
...replay,
|
||||
provenance: { ...replay.provenance, histogramPreserved: true },
|
||||
}).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts deterministic synthetic frames only with L0 and the zero calibration hash', () => {
|
||||
const synthetic = createSyntheticNlosFrame(0, 1_700_000_000_000);
|
||||
expect(validateNlosTrackFrame(synthetic).ok).toBe(true);
|
||||
expect(validateNlosTrackFrame({ ...synthetic, evidenceLevel: 'l1_measured' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_provenance',
|
||||
});
|
||||
expect(validateNlosTrackFrame({ ...synthetic, calibrationHash: 'b'.repeat(64) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_provenance',
|
||||
});
|
||||
expect(validateNlosTrackFrame({
|
||||
...synthetic,
|
||||
provenance: { ...synthetic.provenance, histogramPreserved: true },
|
||||
}).ok).toBe(true);
|
||||
expect(validateNlosTrackFrame({
|
||||
...synthetic,
|
||||
provenance: { ...synthetic.provenance, transientKind: 'raw_histogram' },
|
||||
})).toEqual({ ok: false, reason: 'invalid_provenance' });
|
||||
});
|
||||
|
||||
it('enforces calibrated hashes, unique tracks, and normalized modality weights', () => {
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
expect(validateNlosTrackFrame({ ...frame, calibrationHash: '0'.repeat(64) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_provenance',
|
||||
});
|
||||
expect(validateNlosTrackFrame({ ...frame, tracks: [frame.tracks[0], frame.tracks[0]] })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_provenance',
|
||||
});
|
||||
expect(validateNlosTrackFrame({
|
||||
...frame,
|
||||
tracks: [{
|
||||
...frame.tracks[0],
|
||||
modalityContributions: { lidar: 0.8, csi: 0.8 },
|
||||
}],
|
||||
})).toEqual({ ok: false, reason: 'invalid_shape' });
|
||||
expect(validateNlosTrackFrame({ ...frame, evidenceLevel: 'l3_corroborated' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_provenance',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown fields for a schema version instead of interpreting them ambiguously', () => {
|
||||
expect(validateNlosTrackFrame({ ...createLiveNlosFrameFixture(), trustMe: true })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid_shape',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/ws/sensing');
|
||||
expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://192.168.1.10:3000/api/v1/stream/pose');
|
||||
|
||||
// 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/ws/sensing');
|
||||
expect(capturedUrls[capturedUrls.length - 1]).toBe('ws://myserver.local:8080/api/v1/stream/pose');
|
||||
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/ws/sensing');
|
||||
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/api/v1/stream/pose');
|
||||
ws3.disconnect();
|
||||
|
||||
// Test WSS input
|
||||
const ws4 = createWsService();
|
||||
ws4.connect('wss://secure.example.com');
|
||||
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/ws/sensing');
|
||||
expect(capturedUrls[capturedUrls.length - 1]).toBe('wss://secure.example.com/api/v1/stream/pose');
|
||||
ws4.disconnect();
|
||||
|
||||
// Verify port 3001 is NOT hardcoded anywhere
|
||||
|
||||
81
ui/mobile/src/__tests__/stores/nlosStore.test.ts
Normal file
81
ui/mobile/src/__tests__/stores/nlosStore.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createSyntheticNlosFrame } from '@/services/nlos.service';
|
||||
import { useNlosStore } from '@/stores/nlosStore';
|
||||
import { createLiveNlosFrameFixture } from '@/testUtils/nlosFixtures';
|
||||
import { NLOS_STALE_AFTER_MS } from '@/types/nlos';
|
||||
|
||||
const NOW = 1_700_000_000_100;
|
||||
|
||||
describe('useNlosStore', () => {
|
||||
beforeEach(() => useNlosStore.getState().reset());
|
||||
|
||||
it('accepts authenticated live frames and starts fresh', () => {
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW });
|
||||
expect(useNlosStore.getState()).toMatchObject({ frame, freshness: 'fresh', rejectedFrameCount: 0 });
|
||||
});
|
||||
|
||||
it('rejects a live frame delivered over a replay channel', () => {
|
||||
useNlosStore.getState().ingestFrame({
|
||||
frame: createLiveNlosFrameFixture(),
|
||||
channel: 'deterministic_replay',
|
||||
receivedAtUnixMs: NOW,
|
||||
});
|
||||
expect(useNlosStore.getState()).toMatchObject({
|
||||
frame: null,
|
||||
freshness: 'unknown',
|
||||
lastRejectedReason: 'unauthenticated',
|
||||
rejectedFrameCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects replayed sequence numbers in the same session', () => {
|
||||
const frame = createLiveNlosFrameFixture({ sequence: 8 });
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW });
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW + 1 });
|
||||
expect(useNlosStore.getState().rejectedFrameCount).toBe(1);
|
||||
expect(useNlosStore.getState().lastRejectedReason).toBe('out_of_order');
|
||||
});
|
||||
|
||||
it('clears fresh frames immediately when they become stale', () => {
|
||||
const frame = createLiveNlosFrameFixture({ expiresAtUnixMs: NOW + 4_900 });
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW });
|
||||
useNlosStore.getState().refreshFreshness(NOW + NLOS_STALE_AFTER_MS + 1);
|
||||
expect(useNlosStore.getState()).toMatchObject({ frame: null, freshness: 'stale' });
|
||||
});
|
||||
|
||||
it('fails closed on wall clock rollback', () => {
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW });
|
||||
useNlosStore.getState().refreshFreshness(NOW - 1);
|
||||
expect(useNlosStore.getState()).toMatchObject({ frame: null, freshness: 'stale' });
|
||||
});
|
||||
|
||||
it('clears a previously accepted frame when transport validation rejects input', () => {
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW });
|
||||
useNlosStore.getState().recordRejection('malformed_json');
|
||||
expect(useNlosStore.getState()).toMatchObject({
|
||||
frame: null,
|
||||
freshness: 'unknown',
|
||||
lastRejectedReason: 'malformed_json',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a previously accepted frame immediately when transport closes', () => {
|
||||
const frame = createLiveNlosFrameFixture();
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: NOW });
|
||||
useNlosStore.getState().setStreamStatus('error');
|
||||
expect(useNlosStore.getState()).toMatchObject({
|
||||
frame: null,
|
||||
freshness: 'unknown',
|
||||
streamStatus: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps synthetic replay explicitly synthetic', () => {
|
||||
const frame = createSyntheticNlosFrame(0, NOW);
|
||||
useNlosStore.getState().ingestFrame({ frame, channel: 'deterministic_replay', receivedAtUnixMs: NOW });
|
||||
expect(useNlosStore.getState().frame?.source).toBe('synthetic');
|
||||
expect(useNlosStore.getState().frame?.evidenceLevel).toBe('l0_synthetic');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ describe('useSettingsStore', () => {
|
||||
// Reset to defaults by manually setting all values
|
||||
useSettingsStore.setState({
|
||||
serverUrl: 'http://localhost:3000',
|
||||
nlosServerUrl: 'http://localhost:3000',
|
||||
rssiScanEnabled: false,
|
||||
theme: 'system',
|
||||
alertSoundEnabled: true,
|
||||
@@ -41,6 +42,29 @@ describe('useSettingsStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNlosServerUrl', () => {
|
||||
it('updates NLOS independently from the CSI server URL', () => {
|
||||
useSettingsStore.getState().setNlosServerUrl('https://nlos.example');
|
||||
expect(useSettingsStore.getState().nlosServerUrl).toBe('https://nlos.example');
|
||||
expect(useSettingsStore.getState().serverUrl).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('never persists credentials or URL components outside the server origin', () => {
|
||||
const initial = useSettingsStore.getState().nlosServerUrl;
|
||||
for (const unsafe of [
|
||||
'https://user:secret@nlos.example',
|
||||
'https://nlos.example/path',
|
||||
'https://nlos.example?token=secret',
|
||||
'https://nlos.example#secret',
|
||||
]) {
|
||||
useSettingsStore.getState().setNlosServerUrl(unsafe);
|
||||
expect(useSettingsStore.getState().nlosServerUrl).toBe(initial);
|
||||
}
|
||||
useSettingsStore.getState().setNlosServerUrl('https://nlos.example:443/');
|
||||
expect(useSettingsStore.getState().nlosServerUrl).toBe('https://nlos.example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRssiScanEnabled', () => {
|
||||
it('toggles to true', () => {
|
||||
useSettingsStore.getState().setRssiScanEnabled(true);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const SparklineChart = ({
|
||||
height = defaultHeight,
|
||||
style,
|
||||
}: SparklineChartProps) => {
|
||||
const normalizedData = data.length > 0 ? data : [0];
|
||||
const normalizedData = useMemo(() => (data.length > 0 ? data : [0]), [data]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
@@ -28,14 +28,11 @@ export const SparklineChart = ({
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const yValues = normalizedData.map((value) => Number(value) || 0);
|
||||
const yMin = Math.min(...yValues);
|
||||
const yMax = Math.max(...yValues);
|
||||
const yPadding = yMax - yMin === 0 ? 1 : (yMax - yMin) * 0.2;
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View
|
||||
accessible
|
||||
accessibilityLabel={`Signal history with ${normalizedData.length} samples`}
|
||||
accessibilityRole="image"
|
||||
style={{
|
||||
height,
|
||||
|
||||
85
ui/mobile/src/hooks/useNlosStream.ts
Normal file
85
ui/mobile/src/hooks/useNlosStream.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
configureNlosBearerToken,
|
||||
hasConfiguredNlosBearerToken,
|
||||
nlosService,
|
||||
} from '@/services/nlos.service';
|
||||
import { useNlosStore } from '@/stores/nlosStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
const FRESHNESS_POLL_MS = 250;
|
||||
|
||||
export const useNlosStream = () => {
|
||||
const nlosServerUrl = useSettingsStore((state) => state.nlosServerUrl);
|
||||
const frame = useNlosStore((state) => state.frame);
|
||||
const freshness = useNlosStore((state) => state.freshness);
|
||||
const streamStatus = useNlosStore((state) => state.streamStatus);
|
||||
const lastRejectedReason = useNlosStore((state) => state.lastRejectedReason);
|
||||
const rejectedFrameCount = useNlosStore((state) => state.rejectedFrameCount);
|
||||
const [liveCredentialAvailable, setLiveCredentialAvailable] = useState(
|
||||
hasConfiguredNlosBearerToken,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useNlosStore.getState().reset();
|
||||
const unsubscribeFrame = nlosService.subscribe((event) => {
|
||||
useNlosStore.getState().ingestFrame(event);
|
||||
});
|
||||
const unsubscribeStatus = nlosService.subscribeStatus((status) => {
|
||||
useNlosStore.getState().setStreamStatus(status);
|
||||
});
|
||||
const unsubscribeRejected = nlosService.subscribeRejected((reason) => {
|
||||
useNlosStore.getState().recordRejection(reason);
|
||||
});
|
||||
const freshnessTimer = setInterval(() => {
|
||||
useNlosStore.getState().refreshFreshness(Date.now());
|
||||
}, FRESHNESS_POLL_MS);
|
||||
|
||||
if (hasConfiguredNlosBearerToken()) {
|
||||
void nlosService.connectConfiguredLive(nlosServerUrl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(freshnessTimer);
|
||||
unsubscribeFrame();
|
||||
unsubscribeStatus();
|
||||
unsubscribeRejected();
|
||||
nlosService.disconnect();
|
||||
};
|
||||
}, [nlosServerUrl]);
|
||||
|
||||
const startReplay = useCallback(() => {
|
||||
useNlosStore.getState().reset();
|
||||
nlosService.startDeterministicReplay();
|
||||
}, []);
|
||||
|
||||
const connectLive = useCallback(() => {
|
||||
void nlosService.connectConfiguredLive(nlosServerUrl);
|
||||
}, [nlosServerUrl]);
|
||||
|
||||
const configureCredential = useCallback((token: string): boolean => {
|
||||
const configured = configureNlosBearerToken(token);
|
||||
if (configured) setLiveCredentialAvailable(true);
|
||||
return configured;
|
||||
}, []);
|
||||
|
||||
const forgetCredential = useCallback(() => {
|
||||
configureNlosBearerToken(null);
|
||||
setLiveCredentialAvailable(false);
|
||||
nlosService.disconnect();
|
||||
useNlosStore.getState().reset();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
frame,
|
||||
freshness,
|
||||
streamStatus,
|
||||
lastRejectedReason,
|
||||
rejectedFrameCount,
|
||||
liveCredentialAvailable,
|
||||
configureCredential,
|
||||
forgetCredential,
|
||||
startReplay,
|
||||
connectLive,
|
||||
};
|
||||
};
|
||||
@@ -56,6 +56,7 @@ const wrapLazy = (
|
||||
};
|
||||
|
||||
const LiveScreen = wrapLazy(() => import('../screens/LiveScreen'), 'Live');
|
||||
const NLOSScreen = wrapLazy(() => import('../screens/NLOSScreen'), 'NLOS');
|
||||
const VitalsScreen = wrapLazy(() => import('../screens/VitalsScreen'), 'Vitals');
|
||||
const ZonesScreen = wrapLazy(() => import('../screens/ZonesScreen'), 'Zones');
|
||||
const MATScreen = wrapLazy(() => import('../screens/MATScreen'), 'MAT');
|
||||
@@ -65,6 +66,8 @@ const toIconName = (routeName: keyof MainTabsParamList) => {
|
||||
switch (routeName) {
|
||||
case 'Live':
|
||||
return 'wifi';
|
||||
case 'NLOS':
|
||||
return 'scan';
|
||||
case 'Vitals':
|
||||
return 'heart';
|
||||
case 'Zones':
|
||||
@@ -80,6 +83,7 @@ const toIconName = (routeName: keyof MainTabsParamList) => {
|
||||
|
||||
const screens: ReadonlyArray<{ name: keyof MainTabsParamList; component: React.ComponentType }> = [
|
||||
{ name: 'Live', component: LiveScreen },
|
||||
{ name: 'NLOS', component: NLOSScreen },
|
||||
{ name: 'Vitals', component: VitalsScreen },
|
||||
{ name: 'Zones', component: ZonesScreen },
|
||||
{ name: 'MAT', component: MATScreen },
|
||||
|
||||
@@ -4,6 +4,7 @@ export type RootStackParamList = {
|
||||
|
||||
export type MainTabsParamList = {
|
||||
Live: undefined;
|
||||
NLOS: undefined;
|
||||
Vitals: undefined;
|
||||
Zones: undefined;
|
||||
MAT: undefined;
|
||||
|
||||
@@ -43,7 +43,7 @@ const WebLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
|
||||
return <Viewer frame={frame} onReady={onReady} onFps={onFps} onError={onError} />;
|
||||
};
|
||||
|
||||
const NativeLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
|
||||
const NativeLiveViewer = ({ onReady, onFps, onError }: ViewerProps) => {
|
||||
const webViewRef = useRef(null);
|
||||
const [WVComponent, setWVComponent] = useState<React.ComponentType<any> | null>(null);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, StyleSheet, Text, View } from 'react-native';
|
||||
import { Animated, StyleSheet, Text } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
||||
138
ui/mobile/src/screens/NLOSScreen/HiddenTargetVisualization.tsx
Normal file
138
ui/mobile/src/screens/NLOSScreen/HiddenTargetVisualization.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import Svg, { Circle, Ellipse, Line, Polygon, Rect, Text as SvgText } from 'react-native-svg';
|
||||
import { colors } from '@/theme/colors';
|
||||
import type { NlosFreshness, NlosTrack } from '@/types/nlos';
|
||||
|
||||
export type NlosViewMode = 'plan' | 'perspective';
|
||||
|
||||
interface HiddenTargetVisualizationProps {
|
||||
tracks: NlosTrack[];
|
||||
freshness: NlosFreshness;
|
||||
mode: NlosViewMode;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ProjectedTrack {
|
||||
track: NlosTrack;
|
||||
x: number;
|
||||
y: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
}
|
||||
|
||||
const CANVAS_WIDTH = 360;
|
||||
const CANVAS_HEIGHT = 260;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const resolveTrackColor = (track: NlosTrack, freshness: NlosFreshness): string => {
|
||||
if (freshness !== 'fresh' || track.state === 'unknown') return colors.muted;
|
||||
if (track.state === 'degraded') return colors.warn;
|
||||
return colors.accent;
|
||||
};
|
||||
|
||||
const projectPlan = (track: NlosTrack): ProjectedTrack => {
|
||||
const x = 180 + clamp(track.positionM.x, -6, 6) * 24;
|
||||
const y = 232 - clamp(track.positionM.z, 0, 8) * 25;
|
||||
return {
|
||||
track,
|
||||
x,
|
||||
y,
|
||||
radiusX: clamp(Math.sqrt(track.covarianceDiagonalM2.x) * 24, 5, 28),
|
||||
radiusY: clamp(Math.sqrt(track.covarianceDiagonalM2.z) * 25, 5, 28),
|
||||
velocityX: track.velocityMps.x * 10,
|
||||
velocityY: -track.velocityMps.z * 10,
|
||||
};
|
||||
};
|
||||
|
||||
const projectPerspective = (track: NlosTrack): ProjectedTrack => {
|
||||
const position = track.positionM;
|
||||
const x = 180 + (clamp(position.x, -6, 6) - clamp(position.z, 0, 8)) * 17;
|
||||
const y = 205 + (clamp(position.x, -6, 6) + clamp(position.z, 0, 8)) * 6 - clamp(position.y, 0, 4) * 25;
|
||||
return {
|
||||
track,
|
||||
x,
|
||||
y,
|
||||
radiusX: clamp(Math.sqrt(track.covarianceDiagonalM2.x) * 22, 5, 26),
|
||||
radiusY: clamp(Math.sqrt(track.covarianceDiagonalM2.y + track.covarianceDiagonalM2.z) * 10, 4, 22),
|
||||
velocityX: (track.velocityMps.x - track.velocityMps.z) * 8,
|
||||
velocityY: (track.velocityMps.x + track.velocityMps.z - track.velocityMps.y) * 4,
|
||||
};
|
||||
};
|
||||
|
||||
const PlanScene = () => (
|
||||
<>
|
||||
<Rect x={18} y={18} width={324} height={214} rx={10} fill={colors.surface} stroke={colors.border} />
|
||||
<Rect x={19} y={19} width={322} height={74} rx={9} fill="rgba(255, 165, 2, 0.07)" />
|
||||
<Line x1={24} y1={94} x2={336} y2={94} stroke={colors.warn} strokeWidth={4} />
|
||||
<SvgText x={28} y={84} fill={colors.warn} fontSize={10}>HIDDEN REGION</SvgText>
|
||||
<SvgText x={28} y={112} fill={colors.textSecondary} fontSize={10}>RELAY SURFACE</SvgText>
|
||||
<Circle cx={180} cy={218} r={5} fill={colors.accent} />
|
||||
<Line x1={180} y1={213} x2={180} y2={98} stroke={colors.accentDim} strokeDasharray="5 5" />
|
||||
<SvgText x={190} y={222} fill={colors.textSecondary} fontSize={9}>SENSOR</SvgText>
|
||||
</>
|
||||
);
|
||||
|
||||
const PerspectiveScene = () => (
|
||||
<>
|
||||
<Polygon points="180,38 316,86 180,136 44,86" fill={colors.surface} stroke={colors.border} />
|
||||
<Polygon points="44,86 180,136 180,220 44,166" fill="rgba(26, 34, 51, 0.7)" stroke={colors.border} />
|
||||
<Polygon points="180,136 316,86 316,166 180,220" fill="rgba(17, 24, 39, 0.8)" stroke={colors.border} />
|
||||
<Polygon points="84,72 180,106 276,72 180,38" fill="rgba(255, 165, 2, 0.08)" />
|
||||
<Line x1={84} y1={72} x2={180} y2={106} stroke={colors.warn} strokeWidth={4} />
|
||||
<Line x1={180} y1={106} x2={276} y2={72} stroke={colors.warn} strokeWidth={4} />
|
||||
<SvgText x={119} y={62} fill={colors.warn} fontSize={10}>BEYOND RELAY PLANE</SvgText>
|
||||
<Circle cx={180} cy={205} r={5} fill={colors.accent} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const HiddenTargetVisualization = memo(({
|
||||
tracks,
|
||||
freshness,
|
||||
mode,
|
||||
width,
|
||||
}: HiddenTargetVisualizationProps) => {
|
||||
const projectedTracks = useMemo(
|
||||
() => tracks.map(mode === 'plan' ? projectPlan : projectPerspective),
|
||||
[mode, tracks],
|
||||
);
|
||||
const displayWidth = Math.max(260, Math.min(width, 560));
|
||||
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`${mode === 'plan' ? 'Plan' : 'Perspective'} view of ${tracks.length} hidden target hypotheses`}
|
||||
style={{ alignSelf: 'center', width: displayWidth, aspectRatio: CANVAS_WIDTH / CANVAS_HEIGHT }}
|
||||
>
|
||||
<Svg width="100%" height="100%" viewBox={`0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}`}>
|
||||
{mode === 'plan' ? <PlanScene /> : <PerspectiveScene />}
|
||||
{projectedTracks.map(({ track, x, y, radiusX, radiusY, velocityX, velocityY }) => {
|
||||
const color = resolveTrackColor(track, freshness);
|
||||
return (
|
||||
<React.Fragment key={track.trackId}>
|
||||
<Ellipse
|
||||
cx={x}
|
||||
cy={y}
|
||||
rx={radiusX}
|
||||
ry={radiusY}
|
||||
fill={`${color}18`}
|
||||
stroke={color}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
<Line x1={x} y1={y} x2={x + velocityX} y2={y + velocityY} stroke={color} strokeWidth={2} />
|
||||
<Circle cx={x} cy={y} r={6 + track.confidence * 4} fill={color} stroke="#FFFFFF" strokeWidth={1.5} />
|
||||
<SvgText x={x + 12} y={y - 10} fill={colors.textPrimary} fontSize={10}>
|
||||
{track.trackId}
|
||||
</SvgText>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
HiddenTargetVisualization.displayName = 'HiddenTargetVisualization';
|
||||
98
ui/mobile/src/screens/NLOSScreen/ProvenancePanel.tsx
Normal file
98
ui/mobile/src/screens/NLOSScreen/ProvenancePanel.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing } from '@/theme/spacing';
|
||||
import type { NlosFreshness, NlosStreamStatus, NlosTrackFrame } from '@/types/nlos';
|
||||
|
||||
interface ProvenancePanelProps {
|
||||
frame: NlosTrackFrame | null;
|
||||
freshness: NlosFreshness;
|
||||
streamStatus: NlosStreamStatus;
|
||||
}
|
||||
|
||||
const sourceLabel = (frame: NlosTrackFrame | null): string => {
|
||||
if (!frame) return 'UNKNOWN';
|
||||
if (frame.source === 'synthetic') return 'SYNTHETIC';
|
||||
if (frame.source === 'replay') return 'REPLAY';
|
||||
return 'LIVE';
|
||||
};
|
||||
|
||||
const sourceColor = (frame: NlosTrackFrame | null): string => {
|
||||
if (!frame) return colors.muted;
|
||||
if (frame.source === 'synthetic') return colors.warn;
|
||||
if (frame.source === 'replay') return colors.textSecondary;
|
||||
return colors.success;
|
||||
};
|
||||
|
||||
const humanize = (value: string) => value.replace(/_/g, ' ').toUpperCase();
|
||||
|
||||
const ProvenanceRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<View style={styles.provenanceRow}>
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={styles.provenanceLabel}>{label}</ThemedText>
|
||||
<ThemedText preset="bodySm" numberOfLines={1} style={styles.provenanceValue}>{value}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const ProvenancePanel = ({ frame, freshness, streamStatus }: ProvenancePanelProps) => {
|
||||
const label = sourceLabel(frame);
|
||||
const accent = sourceColor(frame);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.badgeRow}>
|
||||
<ThemedText testID="nlos-provenance-badge" preset="labelMd" style={[styles.badge, { borderColor: accent, color: accent }]}>
|
||||
{label}
|
||||
</ThemedText>
|
||||
<ThemedText testID="nlos-freshness-badge" preset="labelMd" style={{ color: freshness === 'fresh' ? colors.success : freshness === 'stale' ? colors.danger : colors.muted }}>
|
||||
{freshness.toUpperCase()}
|
||||
</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
{humanize(streamStatus)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{frame ? (
|
||||
<View style={styles.grid}>
|
||||
<ProvenanceRow label="Evidence" value={humanize(frame.evidenceLevel)} />
|
||||
<ProvenanceRow label="Transient" value={humanize(frame.provenance.transientKind)} />
|
||||
<ProvenanceRow label="Histograms" value={frame.provenance.histogramPreserved ? 'PRESERVED' : 'NOT PRESENT'} />
|
||||
<ProvenanceRow label="Sensor" value={frame.provenance.sensorModel} />
|
||||
<ProvenanceRow label="Sequence" value={String(frame.sequence)} />
|
||||
</View>
|
||||
) : (
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
No validated frame is available. Unknown evidence is never promoted to live.
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
badge: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
grid: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
provenanceRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
provenanceLabel: { width: 82 },
|
||||
provenanceValue: { flex: 1 },
|
||||
});
|
||||
223
ui/mobile/src/screens/NLOSScreen/index.tsx
Normal file
223
ui/mobile/src/screens/NLOSScreen/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, TextInput, useWindowDimensions, View } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useNlosStream } from '@/hooks/useNlosStream';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing } from '@/theme/spacing';
|
||||
import { HiddenTargetVisualization, type NlosViewMode } from './HiddenTargetVisualization';
|
||||
import { ProvenancePanel } from './ProvenancePanel';
|
||||
|
||||
const ViewModePicker = ({ value, onChange }: { value: NlosViewMode; onChange: (value: NlosViewMode) => void }) => (
|
||||
<View style={styles.picker}>
|
||||
{(['plan', 'perspective'] as const).map((option) => {
|
||||
const selected = option === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
onPress={() => onChange(option)}
|
||||
style={[styles.pickerButton, selected && styles.pickerButtonSelected]}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ color: selected ? colors.accent : colors.textSecondary }}>
|
||||
{option === 'plan' ? '2D PLAN' : '3D VIEW'}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NLOSScreen = () => {
|
||||
const {
|
||||
frame,
|
||||
freshness,
|
||||
streamStatus,
|
||||
lastRejectedReason,
|
||||
rejectedFrameCount,
|
||||
liveCredentialAvailable,
|
||||
configureCredential,
|
||||
forgetCredential,
|
||||
startReplay,
|
||||
connectLive,
|
||||
} = useNlosStream();
|
||||
const [viewMode, setViewMode] = useState<NlosViewMode>('plan');
|
||||
const [credentialDraft, setCredentialDraft] = useState('');
|
||||
const [credentialError, setCredentialError] = useState(false);
|
||||
const { width } = useWindowDimensions();
|
||||
const visualizationWidth = useMemo(() => width - spacing.md * 2, [width]);
|
||||
const isSynthetic = frame?.source === 'synthetic';
|
||||
const visibleTracks = useMemo(
|
||||
() => freshness === 'fresh'
|
||||
? frame?.tracks.filter((track) => track.state !== 'unknown') ?? []
|
||||
: [],
|
||||
[frame, freshness],
|
||||
);
|
||||
const credentialLengthValid = credentialDraft.length >= 32 && credentialDraft.length <= 512;
|
||||
|
||||
const handleConfigureCredential = () => {
|
||||
const configured = configureCredential(credentialDraft);
|
||||
setCredentialError(!configured);
|
||||
if (configured) setCredentialDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ThemedText preset="displayMd">RuView NLOS</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
Hidden target hypotheses from a RuView reconstruction server
|
||||
</ThemedText>
|
||||
</View>
|
||||
<ThemedText preset="labelMd" style={{ color: colors.accent }}>LABS</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.notice}>
|
||||
<ThemedText preset="bodySm" style={{ color: colors.warn }}>
|
||||
This client does not access raw iPhone LiDAR timing data. Safari and Expo display authenticated RuView track frames or visibly watermarked synthetic replay only.
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<ProvenancePanel frame={frame} freshness={freshness} streamStatus={streamStatus} />
|
||||
|
||||
<View style={styles.visualizationCard}>
|
||||
<ViewModePicker value={viewMode} onChange={setViewMode} />
|
||||
<HiddenTargetVisualization
|
||||
tracks={visibleTracks}
|
||||
freshness={freshness}
|
||||
mode={viewMode}
|
||||
width={visualizationWidth}
|
||||
/>
|
||||
{isSynthetic && (
|
||||
<View testID="nlos-synthetic-watermark" pointerEvents="none" style={styles.watermark}>
|
||||
<ThemedText preset="displayMd" style={styles.watermarkText}>SYNTHETIC</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
{freshness === 'stale' && (
|
||||
<View testID="nlos-stale-overlay" pointerEvents="none" style={styles.staleOverlay}>
|
||||
<ThemedText preset="labelLg" style={{ color: colors.danger }}>STALE FRAME</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.metric}>
|
||||
<ThemedText testID="nlos-track-count" preset="displayMd">{visibleTracks.length}</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary">TRACKS</ThemedText>
|
||||
</View>
|
||||
<View style={styles.metric}>
|
||||
<ThemedText testID="nlos-mean-confidence" preset="displayMd">
|
||||
{visibleTracks.length ? `${Math.round(visibleTracks.reduce((sum, track) => sum + track.confidence, 0) / visibleTracks.length * 100)}%` : 'N/A'}
|
||||
</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary">MEAN CONFIDENCE</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable accessibilityRole="button" onPress={startReplay} style={styles.secondaryButton}>
|
||||
<ThemedText preset="labelMd">USE SYNTHETIC REPLAY</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={!liveCredentialAvailable}
|
||||
onPress={connectLive}
|
||||
style={[styles.liveButton, !liveCredentialAvailable && styles.disabledButton]}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ color: colors.bg }}>CONNECT AUTHENTICATED LIVE</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!liveCredentialAvailable ? (
|
||||
<View style={styles.credentialCard}>
|
||||
<ThemedText preset="labelMd">EPHEMERAL LIVE CREDENTIAL</ThemedText>
|
||||
<TextInput
|
||||
testID="nlos-credential-input"
|
||||
accessibilityLabel="Ephemeral NLOS Bearer credential"
|
||||
value={credentialDraft}
|
||||
onChangeText={(value) => {
|
||||
setCredentialDraft(value);
|
||||
setCredentialError(false);
|
||||
}}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
textContentType="oneTimeCode"
|
||||
maxLength={512}
|
||||
placeholder="32 to 512 character pairing credential"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
style={[styles.credentialInput, credentialError && styles.credentialInputError]}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={!credentialLengthValid}
|
||||
onPress={handleConfigureCredential}
|
||||
style={[styles.credentialButton, !credentialLengthValid && styles.disabledButton]}
|
||||
>
|
||||
<ThemedText preset="labelMd">UNLOCK AUTHENTICATED LIVE</ThemedText>
|
||||
</Pressable>
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
A native host or signed in web session may supply this credential automatically. It is held in memory only, sent solely in the ticket request Authorization header, and never stored by this client.
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.credentialReadyRow}>
|
||||
<ThemedText preset="bodySm" style={{ color: colors.success }}>EPHEMERAL CREDENTIAL READY</ThemedText>
|
||||
<Pressable accessibilityRole="button" onPress={forgetCredential}>
|
||||
<ThemedText preset="labelMd" color="textSecondary">FORGET</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{lastRejectedReason && (
|
||||
<ThemedText testID="nlos-rejection" preset="bodySm" style={{ color: colors.danger }}>
|
||||
Rejected {rejectedFrameCount} frame{rejectedFrameCount === 1 ? '' : 's'}; latest reason: {lastRejectedReason}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
export default NLOSScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
content: { padding: spacing.md, paddingBottom: spacing.xxxl, gap: spacing.md },
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
|
||||
notice: {
|
||||
backgroundColor: 'rgba(255, 165, 2, 0.08)',
|
||||
borderColor: 'rgba(255, 165, 2, 0.4)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md,
|
||||
},
|
||||
visualizationCard: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
picker: { flexDirection: 'row', paddingHorizontal: spacing.sm, gap: spacing.sm },
|
||||
pickerButton: { flex: 1, alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 2, borderBottomColor: colors.border },
|
||||
pickerButtonSelected: { borderBottomColor: colors.accent },
|
||||
watermark: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', transform: [{ rotate: '-18deg' }] },
|
||||
watermarkText: { color: 'rgba(255, 165, 2, 0.18)', letterSpacing: 5 },
|
||||
staleOverlay: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(10, 14, 26, 0.7)', alignItems: 'center', justifyContent: 'center' },
|
||||
summaryRow: { flexDirection: 'row', gap: spacing.md },
|
||||
metric: { flex: 1, backgroundColor: colors.surface, borderRadius: 10, padding: spacing.md },
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
|
||||
secondaryButton: { flexGrow: 1, alignItems: 'center', borderColor: colors.border, borderWidth: 1, borderRadius: 8, padding: spacing.md },
|
||||
liveButton: { flexGrow: 1, alignItems: 'center', backgroundColor: colors.accent, borderRadius: 8, padding: spacing.md },
|
||||
disabledButton: { opacity: 0.35 },
|
||||
credentialCard: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, borderRadius: 10, padding: spacing.md, gap: spacing.sm },
|
||||
credentialInput: { borderColor: colors.border, borderWidth: 1, borderRadius: 8, padding: spacing.md, color: colors.textPrimary, backgroundColor: colors.bg },
|
||||
credentialInputError: { borderColor: colors.danger },
|
||||
credentialButton: { alignItems: 'center', borderColor: colors.accent, borderWidth: 1, borderRadius: 8, padding: spacing.md },
|
||||
credentialReadyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderRadius: 10, padding: spacing.md },
|
||||
});
|
||||
65
ui/mobile/src/screens/SettingsScreen/NlosServerUrlInput.tsx
Normal file
65
ui/mobile/src/screens/SettingsScreen/NlosServerUrlInput.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Pressable, TextInput, View } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing } from '@/theme/spacing';
|
||||
import { normalizeNlosServerUrl } from '@/utils/nlosServerUrl';
|
||||
|
||||
interface NlosServerUrlInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const NlosServerUrlInput = ({ value, onChange, onSave }: NlosServerUrlInputProps) => {
|
||||
const validation = normalizeNlosServerUrl(value);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ThemedText preset="labelMd" style={{ marginBottom: spacing.sm }}>
|
||||
RuView NLOS server URL
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
testID="nlos-server-url-input"
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="https://ruview.example.com"
|
||||
keyboardType="url"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: validation.valid ? colors.border : colors.danger,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.textPrimary,
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
}}
|
||||
/>
|
||||
{!validation.valid && (
|
||||
<ThemedText preset="bodySm" style={{ color: colors.danger, marginBottom: spacing.sm }}>
|
||||
{validation.error}
|
||||
</ThemedText>
|
||||
)}
|
||||
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginBottom: spacing.sm }}>
|
||||
Separate from the CSI endpoint. Live access requires an ephemeral Bearer credential.
|
||||
</ThemedText>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={onSave}
|
||||
disabled={!validation.valid}
|
||||
style={{
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: validation.valid ? colors.success : colors.surfaceAlt,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={{ color: colors.textPrimary }}>
|
||||
Save NLOS server
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { Alert, Pressable, Platform } from 'react-native';
|
||||
import { ThemePicker } from './ThemePicker';
|
||||
import { RssiToggle } from './RssiToggle';
|
||||
import { ServerUrlInput } from './ServerUrlInput';
|
||||
import { NlosServerUrlInput } from './NlosServerUrlInput';
|
||||
|
||||
type GlowCardProps = {
|
||||
title: string;
|
||||
@@ -82,19 +83,26 @@ const ScanIntervalPicker = ({
|
||||
|
||||
export const SettingsScreen = () => {
|
||||
const serverUrl = useSettingsStore((state) => state.serverUrl);
|
||||
const nlosServerUrl = useSettingsStore((state) => state.nlosServerUrl);
|
||||
const rssiScanEnabled = useSettingsStore((state) => state.rssiScanEnabled);
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
const setServerUrl = useSettingsStore((state) => state.setServerUrl);
|
||||
const setNlosServerUrl = useSettingsStore((state) => state.setNlosServerUrl);
|
||||
const setRssiScanEnabled = useSettingsStore((state) => state.setRssiScanEnabled);
|
||||
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||
|
||||
const [draftUrl, setDraftUrl] = useState(serverUrl);
|
||||
const [draftNlosUrl, setDraftNlosUrl] = useState(nlosServerUrl);
|
||||
const [scanInterval, setScanInterval] = useState(2);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftUrl(serverUrl);
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftNlosUrl(nlosServerUrl);
|
||||
}, [nlosServerUrl]);
|
||||
|
||||
const intervalSummary = useMemo(() => `${scanInterval}s`, [scanInterval]);
|
||||
|
||||
const handleSaveUrl = () => {
|
||||
@@ -105,6 +113,10 @@ export const SettingsScreen = () => {
|
||||
apiService.setBaseUrl(newUrl);
|
||||
};
|
||||
|
||||
const handleSaveNlosUrl = () => {
|
||||
setNlosServerUrl(draftNlosUrl.trim());
|
||||
};
|
||||
|
||||
const handleOpenGitHub = async () => {
|
||||
const handled = await Linking.canOpenURL('https://github.com');
|
||||
if (!handled) {
|
||||
@@ -126,6 +138,14 @@ export const SettingsScreen = () => {
|
||||
<ServerUrlInput value={draftUrl} onChange={setDraftUrl} onSave={handleSaveUrl} />
|
||||
</GlowCard>
|
||||
|
||||
<GlowCard title="RUVIEW NLOS SERVER">
|
||||
<NlosServerUrlInput
|
||||
value={draftNlosUrl}
|
||||
onChange={setDraftNlosUrl}
|
||||
onSave={handleSaveNlosUrl}
|
||||
/>
|
||||
</GlowCard>
|
||||
|
||||
<GlowCard title="SENSING">
|
||||
<RssiToggle enabled={rssiScanEnabled} onChange={setRssiScanEnabled} />
|
||||
<ThemedText preset="bodyMd" style={{ marginTop: spacing.md }}>
|
||||
|
||||
562
ui/mobile/src/services/nlos.service.ts
Normal file
562
ui/mobile/src/services/nlos.service.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import {
|
||||
NLOS_MAX_MESSAGE_BYTES,
|
||||
NLOS_TRACK_SCHEMA,
|
||||
type NlosFrameEvent,
|
||||
type NlosRejectReason,
|
||||
type NlosStreamStatus,
|
||||
type NlosTrackFrame,
|
||||
} from '@/types/nlos';
|
||||
import { parseNlosTrackFrame, utf8ByteLength } from './nlos.validation';
|
||||
import { normalizeNlosServerUrl } from '@/utils/nlosServerUrl';
|
||||
|
||||
export const NLOS_WS_TICKET_PATH = '/api/v1/nlos/ws-ticket';
|
||||
export const NLOS_TICKET_SCHEMA = 'ruview.nlos.ws-ticket.v1' as const;
|
||||
export const NLOS_AUTHENTICATED_SCHEMA = 'ruview.nlos.authenticated.v1' as const;
|
||||
|
||||
const MAX_TICKET_RESPONSE_BYTES = 8 * 1024;
|
||||
const MAX_TICKET_TTL_MS = 30_000;
|
||||
const MAX_AUTHENTICATED_SESSION_TTL_MS = 60 * 60 * 1_000;
|
||||
const MIN_BEARER_TOKEN_LENGTH = 32;
|
||||
const MAX_BEARER_TOKEN_LENGTH = 512;
|
||||
const MAX_CLOCK_SKEW_MS = 1_000;
|
||||
const TRANSPORT_HANDSHAKE_TIMEOUT_MS = 5_000;
|
||||
const MAX_REPLAY_FPS = 30;
|
||||
const SYNTHETIC_SESSION_ID = 'synthetic-replay-v1';
|
||||
const ZERO_CALIBRATION_HASH = '0'.repeat(64);
|
||||
|
||||
type FrameListener = (event: NlosFrameEvent) => void;
|
||||
type StatusListener = (status: NlosStreamStatus) => void;
|
||||
type RejectListener = (reason: NlosRejectReason) => void;
|
||||
|
||||
interface TicketResponse {
|
||||
schema: typeof NLOS_TICKET_SCHEMA;
|
||||
webSocketUrl: string;
|
||||
expiresAtUnixMs: number;
|
||||
}
|
||||
|
||||
interface AuthenticatedMessage {
|
||||
schema: typeof NLOS_AUTHENTICATED_SCHEMA;
|
||||
sessionId: string;
|
||||
expiresAtUnixMs: number;
|
||||
}
|
||||
|
||||
interface FetchResponseLike {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text: () => Promise<string>;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init: RequestInit) => Promise<FetchResponseLike>;
|
||||
|
||||
interface WebSocketLike {
|
||||
readyState: number;
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
onclose: ((event: { code: number }) => void) | null;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
}
|
||||
|
||||
export interface NlosServiceDependencies {
|
||||
fetch: FetchLike;
|
||||
createWebSocket: (url: string) => WebSocketLike;
|
||||
now: () => number;
|
||||
setInterval: typeof globalThis.setInterval;
|
||||
clearInterval: typeof globalThis.clearInterval;
|
||||
setTimeout: typeof globalThis.setTimeout;
|
||||
clearTimeout: typeof globalThis.clearTimeout;
|
||||
}
|
||||
|
||||
export interface NlosLiveConfig {
|
||||
serverUrl: string;
|
||||
bearerToken: string;
|
||||
}
|
||||
|
||||
let ephemeralBearerToken: string | null = null;
|
||||
|
||||
const isValidBearerToken = (value: string): boolean =>
|
||||
value.length >= MIN_BEARER_TOKEN_LENGTH &&
|
||||
value.length <= MAX_BEARER_TOKEN_LENGTH &&
|
||||
/^[!-~]+$/.test(value);
|
||||
|
||||
/** Stores an NLOS credential in module memory only. It is never persisted. */
|
||||
export const configureNlosBearerToken = (token: string | null): boolean => {
|
||||
if (token === null) {
|
||||
ephemeralBearerToken = null;
|
||||
return true;
|
||||
}
|
||||
if (!isValidBearerToken(token)) return false;
|
||||
ephemeralBearerToken = token;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasConfiguredNlosBearerToken = (): boolean => ephemeralBearerToken !== null;
|
||||
|
||||
const consumeConfiguredNlosBearerToken = (): string | null => ephemeralBearerToken;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
|
||||
const actual = Object.keys(value);
|
||||
return actual.length === keys.length && actual.every((key) => keys.includes(key));
|
||||
};
|
||||
|
||||
const isSafeId = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
value.length >= 1 &&
|
||||
value.length <= 64 &&
|
||||
/^[A-Za-z0-9._:-]+$/.test(value);
|
||||
|
||||
const isSafeUnixMs = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
const parseServerUrl = (raw: string): URL | null => {
|
||||
const validation = normalizeNlosServerUrl(raw);
|
||||
return validation.valid && validation.normalized ? new URL(validation.normalized) : null;
|
||||
};
|
||||
|
||||
const effectivePort = (url: URL): string => {
|
||||
if (url.port) return url.port;
|
||||
return url.protocol === 'https:' || url.protocol === 'wss:' ? '443' : '80';
|
||||
};
|
||||
|
||||
const parseWebSocketUrl = (raw: string, serverUrl: URL): string | null => {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const secure = url.protocol === 'wss:';
|
||||
const loopback = url.protocol === 'ws:' &&
|
||||
(url.hostname === 'localhost' ||
|
||||
url.hostname === '127.0.0.1' ||
|
||||
url.hostname === '[::1]' ||
|
||||
url.hostname === '::1');
|
||||
const expectedProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ticketKeys = Array.from(url.searchParams.keys());
|
||||
const ticket = url.searchParams.get('ticket');
|
||||
if (
|
||||
(!secure && !loopback) ||
|
||||
url.protocol !== expectedProtocol ||
|
||||
url.hostname !== serverUrl.hostname ||
|
||||
effectivePort(url) !== effectivePort(serverUrl) ||
|
||||
url.pathname !== '/api/v1/nlos/ws' ||
|
||||
ticketKeys.length !== 1 ||
|
||||
ticketKeys[0] !== 'ticket' ||
|
||||
!ticket ||
|
||||
!/^[0-9a-f]{64}$/.test(ticket) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash
|
||||
) return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseTicket = (raw: string, now: number, serverUrl: URL): TicketResponse | null => {
|
||||
if (utf8ByteLength(raw) > MAX_TICKET_RESPONSE_BYTES) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, ['schema', 'webSocketUrl', 'expiresAtUnixMs']) ||
|
||||
value.schema !== NLOS_TICKET_SCHEMA ||
|
||||
typeof value.webSocketUrl !== 'string' ||
|
||||
parseWebSocketUrl(value.webSocketUrl, serverUrl) === null ||
|
||||
!isSafeUnixMs(value.expiresAtUnixMs) ||
|
||||
value.expiresAtUnixMs <= now ||
|
||||
value.expiresAtUnixMs - now > MAX_TICKET_TTL_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as unknown as TicketResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseAuthenticatedMessage = (raw: string, now: number): AuthenticatedMessage | null => {
|
||||
if (utf8ByteLength(raw) > NLOS_MAX_MESSAGE_BYTES) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, ['schema', 'sessionId', 'expiresAtUnixMs']) ||
|
||||
value.schema !== NLOS_AUTHENTICATED_SCHEMA ||
|
||||
!isSafeId(value.sessionId) ||
|
||||
!isSafeUnixMs(value.expiresAtUnixMs) ||
|
||||
value.expiresAtUnixMs <= now ||
|
||||
value.expiresAtUnixMs - now > MAX_AUTHENTICATED_SESSION_TTL_MS + MAX_CLOCK_SKEW_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as unknown as AuthenticatedMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSyntheticNlosFrame = (
|
||||
sequence: number,
|
||||
now: number,
|
||||
fps = 15,
|
||||
): NlosTrackFrame => {
|
||||
if (
|
||||
!Number.isSafeInteger(sequence) ||
|
||||
sequence < 0 ||
|
||||
!Number.isSafeInteger(now) ||
|
||||
now < 0 ||
|
||||
now > Number.MAX_SAFE_INTEGER - 1_000 ||
|
||||
!Number.isFinite(fps) ||
|
||||
fps < 1 ||
|
||||
fps > MAX_REPLAY_FPS
|
||||
) {
|
||||
throw new RangeError('Synthetic sequence and timestamp must be safe unsigned integers');
|
||||
}
|
||||
const phase = sequence / 12;
|
||||
const phaseRate = fps / 12;
|
||||
const x = 2.4 + Math.sin(phase) * 1.3;
|
||||
const y = 1.05 + Math.sin(phase * 0.4) * 0.08;
|
||||
const zPhase = phase * 0.7;
|
||||
const z = 3.3 + Math.cos(zPhase) * 0.9;
|
||||
const vx = Math.cos(phase) * 1.3 * phaseRate;
|
||||
const vy = Math.cos(phase * 0.4) * 0.08 * 0.4 * phaseRate;
|
||||
const vz = -Math.sin(zPhase) * 0.9 * 0.7 * phaseRate;
|
||||
|
||||
return {
|
||||
schema: NLOS_TRACK_SCHEMA,
|
||||
sessionId: SYNTHETIC_SESSION_ID,
|
||||
sequence,
|
||||
capturedAtUnixMs: now,
|
||||
expiresAtUnixMs: now + 1_000,
|
||||
source: 'synthetic',
|
||||
evidenceLevel: 'l0_synthetic',
|
||||
algorithmVersion: 'synthetic-replay-v1',
|
||||
calibrationHash: ZERO_CALIBRATION_HASH,
|
||||
provenance: {
|
||||
sensorId: 'synthetic-sensor',
|
||||
sensorModel: 'deterministic-fixture',
|
||||
firmwareVersion: 'fixture-v1',
|
||||
transientKind: 'replay',
|
||||
histogramPreserved: false,
|
||||
transport: 'replay',
|
||||
},
|
||||
tracks: [
|
||||
{
|
||||
trackId: 'synthetic-target-1',
|
||||
state: 'tracking',
|
||||
positionM: { x, y, z },
|
||||
velocityMps: { x: vx, y: vy, z: vz },
|
||||
covarianceDiagonalM2: { x: 0.12, y: 0.18, z: 0.14 },
|
||||
confidence: 0.72,
|
||||
posteriorEntropy: 0.68,
|
||||
signalQuality: 0.64,
|
||||
modalityContributions: { lidar: 0.55, csi: 0.45 },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const defaultDependencies = (): NlosServiceDependencies => ({
|
||||
fetch: (input, init) => fetch(input, init) as Promise<FetchResponseLike>,
|
||||
createWebSocket: (url) => new WebSocket(url) as unknown as WebSocketLike,
|
||||
now: Date.now,
|
||||
setInterval: globalThis.setInterval.bind(globalThis),
|
||||
clearInterval: globalThis.clearInterval.bind(globalThis),
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
});
|
||||
|
||||
export class NlosService {
|
||||
private readonly dependencies: NlosServiceDependencies;
|
||||
private frameListeners = new Set<FrameListener>();
|
||||
private statusListeners = new Set<StatusListener>();
|
||||
private rejectListeners = new Set<RejectListener>();
|
||||
private socket: WebSocketLike | null = null;
|
||||
private replayTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private authenticationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private sessionExpiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private ticketAbortController: AbortController | null = null;
|
||||
private status: NlosStreamStatus = 'idle';
|
||||
private generation = 0;
|
||||
private authenticatedSession: AuthenticatedMessage | null = null;
|
||||
private lastSequence = -1;
|
||||
private replaySequence = 0;
|
||||
|
||||
constructor(dependencies: NlosServiceDependencies = defaultDependencies()) {
|
||||
this.dependencies = dependencies;
|
||||
}
|
||||
|
||||
subscribe(listener: FrameListener): () => void {
|
||||
this.frameListeners.add(listener);
|
||||
return () => this.frameListeners.delete(listener);
|
||||
}
|
||||
|
||||
subscribeStatus(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
subscribeRejected(listener: RejectListener): () => void {
|
||||
this.rejectListeners.add(listener);
|
||||
return () => this.rejectListeners.delete(listener);
|
||||
}
|
||||
|
||||
getStatus(): NlosStreamStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
async connectLive(config: NlosLiveConfig): Promise<boolean> {
|
||||
this.stopTransport();
|
||||
const generation = this.generation;
|
||||
const serverUrl = parseServerUrl(config.serverUrl);
|
||||
if (!serverUrl || !isValidBearerToken(config.bearerToken)) {
|
||||
this.setStatus('error');
|
||||
this.emitRejected('unauthenticated');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setStatus('authenticating');
|
||||
const ticketUrl = new URL(NLOS_WS_TICKET_PATH, serverUrl).toString();
|
||||
const abortController = new AbortController();
|
||||
this.ticketAbortController = abortController;
|
||||
const ticketTimer = this.dependencies.setTimeout(
|
||||
() => abortController.abort(),
|
||||
TRANSPORT_HANDSHAKE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.dependencies.fetch(ticketUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${config.bearerToken}`,
|
||||
},
|
||||
body: '',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
signal: abortController.signal,
|
||||
});
|
||||
if (generation !== this.generation) return false;
|
||||
if (!response.ok) {
|
||||
this.setStatus('error');
|
||||
this.emitRejected('unauthenticated');
|
||||
return false;
|
||||
}
|
||||
|
||||
const ticket = parseTicket(await response.text(), this.dependencies.now(), serverUrl);
|
||||
if (generation !== this.generation) return false;
|
||||
if (!ticket) {
|
||||
this.setStatus('error');
|
||||
this.emitRejected('invalid_shape');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.openSocket(ticket.webSocketUrl, generation);
|
||||
return true;
|
||||
} catch {
|
||||
if (generation === this.generation) {
|
||||
this.setStatus('error');
|
||||
this.emitRejected('unauthenticated');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
this.dependencies.clearTimeout(ticketTimer);
|
||||
if (this.ticketAbortController === abortController) this.ticketAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
connectConfiguredLive(serverUrl: string): Promise<boolean> {
|
||||
const token = consumeConfiguredNlosBearerToken();
|
||||
if (!token) {
|
||||
this.setStatus('error');
|
||||
this.emitRejected('unauthenticated');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this.connectLive({ serverUrl, bearerToken: token });
|
||||
}
|
||||
|
||||
startDeterministicReplay(fps = 15): void {
|
||||
this.stopTransport();
|
||||
const requestedFps = Number.isFinite(fps) ? Math.floor(fps) : 15;
|
||||
const boundedFps = Math.max(1, Math.min(MAX_REPLAY_FPS, requestedFps));
|
||||
const emitFrame = () => {
|
||||
const receivedAtUnixMs = this.dependencies.now();
|
||||
const frame = createSyntheticNlosFrame(this.replaySequence, receivedAtUnixMs, boundedFps);
|
||||
this.replaySequence += 1;
|
||||
this.emitFrame({ frame, channel: 'deterministic_replay', receivedAtUnixMs });
|
||||
};
|
||||
|
||||
this.setStatus('synthetic_replay');
|
||||
emitFrame();
|
||||
this.replayTimer = this.dependencies.setInterval(emitFrame, Math.ceil(1_000 / boundedFps));
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopTransport();
|
||||
this.setStatus('idle');
|
||||
}
|
||||
|
||||
private openSocket(url: string, generation: number): void {
|
||||
this.setStatus('connecting');
|
||||
const socket = this.dependencies.createWebSocket(url);
|
||||
this.socket = socket;
|
||||
this.authenticationTimer = this.dependencies.setTimeout(() => {
|
||||
if (generation !== this.generation || this.authenticatedSession) return;
|
||||
this.authenticationTimer = null;
|
||||
this.emitRejected('unauthenticated');
|
||||
this.setStatus('error');
|
||||
socket.close(1008, 'authentication timeout');
|
||||
}, TRANSPORT_HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (generation === this.generation) this.setStatus('connecting');
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (generation !== this.generation || socket !== this.socket) return;
|
||||
this.handleSocketMessage(event.data);
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (generation === this.generation) this.setStatus('error');
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (generation !== this.generation) return;
|
||||
this.socket = null;
|
||||
this.authenticatedSession = null;
|
||||
this.clearAuthenticationTimer();
|
||||
this.clearSessionExpiryTimer();
|
||||
if (event.code !== 1000) this.setStatus('error');
|
||||
else this.setStatus('idle');
|
||||
};
|
||||
}
|
||||
|
||||
private handleSocketMessage(data: unknown): void {
|
||||
if (typeof data !== 'string') {
|
||||
this.emitRejected('unsupported_binary');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.dependencies.now();
|
||||
if (!this.authenticatedSession) {
|
||||
const authenticated = parseAuthenticatedMessage(data, now);
|
||||
if (!authenticated) {
|
||||
this.emitRejected('unauthenticated');
|
||||
this.clearAuthenticationTimer();
|
||||
this.setStatus('error');
|
||||
this.socket?.close(1008, 'authentication required');
|
||||
return;
|
||||
}
|
||||
this.authenticatedSession = authenticated;
|
||||
this.lastSequence = -1;
|
||||
this.clearAuthenticationTimer();
|
||||
this.scheduleSessionExpiry(authenticated, now);
|
||||
this.setStatus('live');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.authenticatedSession.expiresAtUnixMs <= now) {
|
||||
this.emitRejected('unauthenticated');
|
||||
this.socket?.close(1008, 'session expired');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseNlosTrackFrame(data);
|
||||
if (!parsed.ok) {
|
||||
this.emitRejected(parsed.reason);
|
||||
return;
|
||||
}
|
||||
const frame = parsed.value;
|
||||
const authenticatedLive =
|
||||
frame.source === 'live' &&
|
||||
frame.provenance.transport === 'ruview_server' &&
|
||||
frame.provenance.histogramPreserved;
|
||||
const authenticatedSynthetic =
|
||||
frame.source === 'synthetic' && frame.provenance.transport === 'replay';
|
||||
if (!authenticatedLive && !authenticatedSynthetic) {
|
||||
this.emitRejected('invalid_provenance');
|
||||
return;
|
||||
}
|
||||
if (frame.sessionId !== this.authenticatedSession.sessionId) {
|
||||
this.emitRejected('session_mismatch');
|
||||
return;
|
||||
}
|
||||
if (frame.sequence <= this.lastSequence) {
|
||||
this.emitRejected('out_of_order');
|
||||
return;
|
||||
}
|
||||
if (frame.capturedAtUnixMs > now + MAX_CLOCK_SKEW_MS) {
|
||||
this.emitRejected('future_frame');
|
||||
return;
|
||||
}
|
||||
if (frame.expiresAtUnixMs <= now) {
|
||||
this.emitRejected('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSequence = frame.sequence;
|
||||
this.emitFrame({ frame, channel: 'authenticated_stream', receivedAtUnixMs: now });
|
||||
}
|
||||
|
||||
private stopTransport(): void {
|
||||
this.generation += 1;
|
||||
this.authenticatedSession = null;
|
||||
this.lastSequence = -1;
|
||||
this.replaySequence = 0;
|
||||
this.ticketAbortController?.abort();
|
||||
this.ticketAbortController = null;
|
||||
this.clearAuthenticationTimer();
|
||||
this.clearSessionExpiryTimer();
|
||||
if (this.replayTimer !== null) {
|
||||
this.dependencies.clearInterval(this.replayTimer);
|
||||
this.replayTimer = null;
|
||||
}
|
||||
if (this.socket) {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
socket.close(1000, 'client disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: NlosStreamStatus): void {
|
||||
if (status === this.status) return;
|
||||
this.status = status;
|
||||
this.statusListeners.forEach((listener) => listener(status));
|
||||
}
|
||||
|
||||
private clearAuthenticationTimer(): void {
|
||||
if (this.authenticationTimer !== null) {
|
||||
this.dependencies.clearTimeout(this.authenticationTimer);
|
||||
this.authenticationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSessionExpiry(session: AuthenticatedMessage, now: number): void {
|
||||
this.clearSessionExpiryTimer();
|
||||
const delay = Math.max(0, session.expiresAtUnixMs - now);
|
||||
this.sessionExpiryTimer = this.dependencies.setTimeout(() => {
|
||||
if (this.authenticatedSession !== session) return;
|
||||
this.sessionExpiryTimer = null;
|
||||
this.authenticatedSession = null;
|
||||
this.emitRejected('unauthenticated');
|
||||
this.setStatus('error');
|
||||
this.socket?.close(1008, 'session expired');
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearSessionExpiryTimer(): void {
|
||||
if (this.sessionExpiryTimer !== null) {
|
||||
this.dependencies.clearTimeout(this.sessionExpiryTimer);
|
||||
this.sessionExpiryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitFrame(event: NlosFrameEvent): void {
|
||||
this.frameListeners.forEach((listener) => listener(event));
|
||||
}
|
||||
|
||||
private emitRejected(reason: NlosRejectReason): void {
|
||||
this.rejectListeners.forEach((listener) => listener(reason));
|
||||
}
|
||||
}
|
||||
|
||||
export const nlosService = new NlosService();
|
||||
248
ui/mobile/src/services/nlos.validation.ts
Normal file
248
ui/mobile/src/services/nlos.validation.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
NLOS_MAX_EXPIRY_WINDOW_MS,
|
||||
NLOS_MAX_MESSAGE_BYTES,
|
||||
NLOS_MAX_TRACKS,
|
||||
NLOS_TRACK_SCHEMA,
|
||||
type NlosEvidenceLevel,
|
||||
type NlosProvenance,
|
||||
type NlosRejectReason,
|
||||
type NlosTrack,
|
||||
type NlosTrackFrame,
|
||||
type NlosValidationResult,
|
||||
type NlosVector3,
|
||||
} from '@/types/nlos';
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
|
||||
const CALIBRATION_HASH = /^[0-9a-f]{64}$/;
|
||||
const ZERO_CALIBRATION_HASH = '0'.repeat(64);
|
||||
const EVIDENCE_LEVELS: ReadonlyArray<NlosEvidenceLevel> = [
|
||||
'l0_synthetic',
|
||||
'l1_measured',
|
||||
'l2_calibrated',
|
||||
'l3_corroborated',
|
||||
];
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
|
||||
const actual = Object.keys(value);
|
||||
return actual.length === keys.length && actual.every((key) => keys.includes(key));
|
||||
};
|
||||
|
||||
const isSafeId = (value: unknown, maxLength = 64): value is string =>
|
||||
typeof value === 'string' && value.length >= 1 && value.length <= maxLength && SAFE_ID.test(value);
|
||||
|
||||
const isSafeUint = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
const isFiniteInRange = (value: unknown, min: number, max: number): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
|
||||
|
||||
const isVector = (value: unknown, absoluteBound: number, nonNegative = false): value is NlosVector3 => {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['x', 'y', 'z'])) return false;
|
||||
const min = nonNegative ? 0 : -absoluteBound;
|
||||
return (
|
||||
isFiniteInRange(value.x, min, absoluteBound) &&
|
||||
isFiniteInRange(value.y, min, absoluteBound) &&
|
||||
isFiniteInRange(value.z, min, absoluteBound)
|
||||
);
|
||||
};
|
||||
|
||||
const isProvenance = (value: unknown): value is NlosProvenance => {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, [
|
||||
'sensorId',
|
||||
'sensorModel',
|
||||
'firmwareVersion',
|
||||
'transientKind',
|
||||
'histogramPreserved',
|
||||
'transport',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isSafeId(value.sensorId, 64) &&
|
||||
isSafeId(value.sensorModel, 64) &&
|
||||
isSafeId(value.firmwareVersion, 64) &&
|
||||
(value.transientKind === 'raw_histogram' ||
|
||||
value.transientKind === 'compact_normalized_histogram' ||
|
||||
value.transientKind === 'depth_only' ||
|
||||
value.transientKind === 'replay') &&
|
||||
typeof value.histogramPreserved === 'boolean' &&
|
||||
(value.transport === 'usb_serial' || value.transport === 'ruview_server' || value.transport === 'replay')
|
||||
);
|
||||
};
|
||||
|
||||
const isTrack = (value: unknown): value is NlosTrack => {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!hasExactKeys(value, [
|
||||
'trackId',
|
||||
'state',
|
||||
'positionM',
|
||||
'velocityMps',
|
||||
'covarianceDiagonalM2',
|
||||
'confidence',
|
||||
'posteriorEntropy',
|
||||
'signalQuality',
|
||||
'modalityContributions',
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isRecord(value.modalityContributions) || !hasExactKeys(value.modalityContributions, ['lidar', 'csi'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lidar = value.modalityContributions.lidar;
|
||||
const csi = value.modalityContributions.csi;
|
||||
return (
|
||||
isSafeId(value.trackId) &&
|
||||
(value.state === 'tracking' || value.state === 'degraded' || value.state === 'unknown') &&
|
||||
isVector(value.positionM, 100) &&
|
||||
isVector(value.velocityMps, 20) &&
|
||||
isVector(value.covarianceDiagonalM2, 10, true) &&
|
||||
isFiniteInRange(value.confidence, 0, 1) &&
|
||||
typeof value.posteriorEntropy === 'number' &&
|
||||
Number.isFinite(value.posteriorEntropy) &&
|
||||
value.posteriorEntropy >= 0 &&
|
||||
isFiniteInRange(value.signalQuality, 0, 1) &&
|
||||
isFiniteInRange(lidar, 0, 1) &&
|
||||
isFiniteInRange(csi, 0, 1) &&
|
||||
lidar + csi >= 0.999 &&
|
||||
lidar + csi <= 1.001
|
||||
);
|
||||
};
|
||||
|
||||
const classifyShapeFailure = (value: Record<string, unknown>): NlosRejectReason => {
|
||||
if (value.schema !== NLOS_TRACK_SCHEMA) return 'invalid_schema';
|
||||
if (
|
||||
!isSafeUint(value.sequence) ||
|
||||
!isSafeUint(value.capturedAtUnixMs) ||
|
||||
!isSafeUint(value.expiresAtUnixMs) ||
|
||||
!Array.isArray(value.tracks) ||
|
||||
value.tracks.length > NLOS_MAX_TRACKS
|
||||
) {
|
||||
return 'invalid_bounds';
|
||||
}
|
||||
return 'invalid_shape';
|
||||
};
|
||||
|
||||
export const utf8ByteLength = (value: string): number => {
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code < 0x80) bytes += 1;
|
||||
else if (code < 0x800) bytes += 2;
|
||||
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
} else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const validateNlosTrackFrame = (value: unknown): NlosValidationResult => {
|
||||
if (!isRecord(value)) return { ok: false, reason: 'invalid_shape' };
|
||||
if (
|
||||
!hasExactKeys(value, [
|
||||
'schema',
|
||||
'sessionId',
|
||||
'sequence',
|
||||
'capturedAtUnixMs',
|
||||
'expiresAtUnixMs',
|
||||
'source',
|
||||
'evidenceLevel',
|
||||
'algorithmVersion',
|
||||
'calibrationHash',
|
||||
'provenance',
|
||||
'tracks',
|
||||
]) ||
|
||||
value.schema !== NLOS_TRACK_SCHEMA ||
|
||||
!isSafeId(value.sessionId) ||
|
||||
!isSafeUint(value.sequence) ||
|
||||
!isSafeUint(value.capturedAtUnixMs) ||
|
||||
!isSafeUint(value.expiresAtUnixMs) ||
|
||||
(value.source !== 'live' && value.source !== 'replay' && value.source !== 'synthetic') ||
|
||||
!EVIDENCE_LEVELS.includes(value.evidenceLevel as NlosEvidenceLevel) ||
|
||||
!isSafeId(value.algorithmVersion) ||
|
||||
typeof value.calibrationHash !== 'string' ||
|
||||
!CALIBRATION_HASH.test(value.calibrationHash) ||
|
||||
!isProvenance(value.provenance) ||
|
||||
!Array.isArray(value.tracks) ||
|
||||
value.tracks.length > NLOS_MAX_TRACKS ||
|
||||
!value.tracks.every(isTrack)
|
||||
) {
|
||||
return { ok: false, reason: classifyShapeFailure(value) };
|
||||
}
|
||||
|
||||
const frame = value as unknown as NlosTrackFrame;
|
||||
if (frame.evidenceLevel === 'l3_corroborated') {
|
||||
return { ok: false, reason: 'invalid_provenance' };
|
||||
}
|
||||
const expiryWindow = frame.expiresAtUnixMs - frame.capturedAtUnixMs;
|
||||
if (expiryWindow <= 0 || expiryWindow > NLOS_MAX_EXPIRY_WINDOW_MS) {
|
||||
return { ok: false, reason: 'invalid_bounds' };
|
||||
}
|
||||
|
||||
const evidenceIndex = EVIDENCE_LEVELS.indexOf(frame.evidenceLevel);
|
||||
const liveProvenanceValid =
|
||||
frame.source !== 'live' ||
|
||||
(evidenceIndex >= EVIDENCE_LEVELS.indexOf('l1_measured') &&
|
||||
frame.provenance.histogramPreserved &&
|
||||
frame.provenance.transientKind !== 'depth_only' &&
|
||||
frame.provenance.transientKind !== 'replay' &&
|
||||
frame.provenance.transport !== 'replay');
|
||||
const syntheticProvenanceValid =
|
||||
frame.source !== 'synthetic' ||
|
||||
(frame.evidenceLevel === 'l0_synthetic' &&
|
||||
frame.calibrationHash === ZERO_CALIBRATION_HASH &&
|
||||
frame.provenance.transport === 'replay' &&
|
||||
frame.provenance.transientKind === 'replay');
|
||||
const replayProvenanceValid =
|
||||
frame.source !== 'replay' ||
|
||||
(frame.provenance.transport === 'replay' &&
|
||||
frame.provenance.transientKind === 'replay' &&
|
||||
frame.provenance.histogramPreserved);
|
||||
const depthOnlyNotLive = frame.provenance.transientKind !== 'depth_only' || frame.source !== 'live';
|
||||
const calibratedHashValid =
|
||||
evidenceIndex < EVIDENCE_LEVELS.indexOf('l2_calibrated') ||
|
||||
frame.calibrationHash !== ZERO_CALIBRATION_HASH;
|
||||
const trackIds = new Set(frame.tracks.map((track) => track.trackId));
|
||||
const trackIdsUnique = trackIds.size === frame.tracks.length;
|
||||
|
||||
if (
|
||||
!liveProvenanceValid ||
|
||||
!syntheticProvenanceValid ||
|
||||
!replayProvenanceValid ||
|
||||
!depthOnlyNotLive ||
|
||||
!calibratedHashValid ||
|
||||
!trackIdsUnique
|
||||
) {
|
||||
return { ok: false, reason: 'invalid_provenance' };
|
||||
}
|
||||
|
||||
return { ok: true, value: frame };
|
||||
};
|
||||
|
||||
export const parseNlosTrackFrame = (raw: string): NlosValidationResult => {
|
||||
if (utf8ByteLength(raw) > NLOS_MAX_MESSAGE_BYTES) {
|
||||
return { ok: false, reason: 'message_too_large' };
|
||||
}
|
||||
|
||||
try {
|
||||
return validateNlosTrackFrame(JSON.parse(raw) as unknown);
|
||||
} catch {
|
||||
return { ok: false, reason: 'malformed_json' };
|
||||
}
|
||||
};
|
||||
137
ui/mobile/src/stores/nlosStore.ts
Normal file
137
ui/mobile/src/stores/nlosStore.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { create } from 'zustand';
|
||||
import { validateNlosTrackFrame } from '@/services/nlos.validation';
|
||||
import {
|
||||
NLOS_STALE_AFTER_MS,
|
||||
type NlosFrameEvent,
|
||||
type NlosFreshness,
|
||||
type NlosRejectReason,
|
||||
type NlosStreamStatus,
|
||||
type NlosTrackFrame,
|
||||
} from '@/types/nlos';
|
||||
|
||||
export interface NlosState {
|
||||
streamStatus: NlosStreamStatus;
|
||||
freshness: NlosFreshness;
|
||||
frame: NlosTrackFrame | null;
|
||||
lastReceivedAtUnixMs: number | null;
|
||||
lastRejectedReason: NlosRejectReason | null;
|
||||
rejectedFrameCount: number;
|
||||
ingestFrame: (event: NlosFrameEvent) => void;
|
||||
setStreamStatus: (status: NlosStreamStatus) => void;
|
||||
recordRejection: (reason: NlosRejectReason) => void;
|
||||
refreshFreshness: (now: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
streamStatus: 'idle' as NlosStreamStatus,
|
||||
freshness: 'unknown' as NlosFreshness,
|
||||
frame: null as NlosTrackFrame | null,
|
||||
lastReceivedAtUnixMs: null as number | null,
|
||||
lastRejectedReason: null as NlosRejectReason | null,
|
||||
rejectedFrameCount: 0,
|
||||
};
|
||||
|
||||
export const useNlosStore = create<NlosState>((set) => ({
|
||||
...initialState,
|
||||
|
||||
ingestFrame: (event) => {
|
||||
const validation = validateNlosTrackFrame(event.frame);
|
||||
if (!validation.ok) {
|
||||
set((state) => ({
|
||||
lastRejectedReason: validation.reason,
|
||||
rejectedFrameCount: state.rejectedFrameCount + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = validation.value;
|
||||
const channelValid =
|
||||
(event.channel === 'authenticated_stream' &&
|
||||
(frame.source === 'live' || frame.source === 'synthetic')) ||
|
||||
(event.channel === 'deterministic_replay' && frame.source === 'synthetic');
|
||||
if (!channelValid) {
|
||||
set((state) => ({
|
||||
lastRejectedReason: frame.source === 'live' ? 'unauthenticated' : 'invalid_provenance',
|
||||
rejectedFrameCount: state.rejectedFrameCount + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (
|
||||
state.frame?.sessionId === frame.sessionId &&
|
||||
frame.sequence <= state.frame.sequence
|
||||
) {
|
||||
return {
|
||||
lastRejectedReason: 'out_of_order',
|
||||
rejectedFrameCount: state.rejectedFrameCount + 1,
|
||||
};
|
||||
}
|
||||
if (frame.expiresAtUnixMs <= event.receivedAtUnixMs) {
|
||||
return {
|
||||
lastRejectedReason: 'expired',
|
||||
rejectedFrameCount: state.rejectedFrameCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
frame,
|
||||
freshness: 'fresh',
|
||||
lastReceivedAtUnixMs: event.receivedAtUnixMs,
|
||||
lastRejectedReason: null,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setStreamStatus: (streamStatus) =>
|
||||
set((state) => {
|
||||
if (streamStatus === 'live' || streamStatus === 'synthetic_replay') {
|
||||
return { streamStatus };
|
||||
}
|
||||
if (
|
||||
state.frame === null &&
|
||||
state.freshness === 'unknown' &&
|
||||
state.lastReceivedAtUnixMs === null
|
||||
) {
|
||||
return { streamStatus };
|
||||
}
|
||||
return {
|
||||
streamStatus,
|
||||
frame: null,
|
||||
freshness: 'unknown',
|
||||
lastReceivedAtUnixMs: null,
|
||||
};
|
||||
}),
|
||||
|
||||
recordRejection: (reason) =>
|
||||
set((state) => ({
|
||||
frame: null,
|
||||
freshness: 'unknown',
|
||||
lastReceivedAtUnixMs: null,
|
||||
lastRejectedReason: reason,
|
||||
rejectedFrameCount: state.rejectedFrameCount + 1,
|
||||
})),
|
||||
|
||||
refreshFreshness: (now) =>
|
||||
set((state) => {
|
||||
if (!state.frame || state.lastReceivedAtUnixMs === null) {
|
||||
return state.freshness === 'unknown' ? {} : { freshness: 'unknown' };
|
||||
}
|
||||
const stale =
|
||||
now < state.lastReceivedAtUnixMs ||
|
||||
now >= state.frame.expiresAtUnixMs ||
|
||||
now - state.lastReceivedAtUnixMs > NLOS_STALE_AFTER_MS;
|
||||
const nextFreshness: NlosFreshness = stale ? 'stale' : 'fresh';
|
||||
if (stale) {
|
||||
return {
|
||||
frame: null,
|
||||
freshness: nextFreshness,
|
||||
lastReceivedAtUnixMs: null,
|
||||
};
|
||||
}
|
||||
return state.freshness === nextFreshness ? {} : { freshness: nextFreshness };
|
||||
}),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
@@ -1,15 +1,18 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import { normalizeNlosServerUrl } from '@/utils/nlosServerUrl';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface SettingsState {
|
||||
serverUrl: string;
|
||||
nlosServerUrl: string;
|
||||
rssiScanEnabled: boolean;
|
||||
theme: Theme;
|
||||
alertSoundEnabled: boolean;
|
||||
setServerUrl: (url: string) => void;
|
||||
setNlosServerUrl: (url: string) => void;
|
||||
setRssiScanEnabled: (value: boolean) => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setAlertSoundEnabled: (value: boolean) => void;
|
||||
@@ -19,6 +22,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
serverUrl: 'http://localhost:3000',
|
||||
nlosServerUrl: 'http://localhost:3000',
|
||||
rssiScanEnabled: false,
|
||||
theme: 'system',
|
||||
alertSoundEnabled: true,
|
||||
@@ -27,6 +31,13 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
set({ serverUrl: url });
|
||||
},
|
||||
|
||||
setNlosServerUrl: (url) => {
|
||||
const validation = normalizeNlosServerUrl(url);
|
||||
if (validation.valid && validation.normalized) {
|
||||
set({ nlosServerUrl: validation.normalized });
|
||||
}
|
||||
},
|
||||
|
||||
setRssiScanEnabled: (value) => {
|
||||
set({ rssiScanEnabled: value });
|
||||
},
|
||||
|
||||
37
ui/mobile/src/testUtils/nlosFixtures.ts
Normal file
37
ui/mobile/src/testUtils/nlosFixtures.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NLOS_TRACK_SCHEMA, type NlosTrackFrame } from '@/types/nlos';
|
||||
|
||||
export const createLiveNlosFrameFixture = (
|
||||
overrides: Partial<NlosTrackFrame> = {},
|
||||
): NlosTrackFrame => ({
|
||||
schema: NLOS_TRACK_SCHEMA,
|
||||
sessionId: 'live-session-1',
|
||||
sequence: 1,
|
||||
capturedAtUnixMs: 1_700_000_000_000,
|
||||
expiresAtUnixMs: 1_700_000_001_000,
|
||||
source: 'live',
|
||||
evidenceLevel: 'l2_calibrated',
|
||||
algorithmVersion: 'nlos-inversion-v1',
|
||||
calibrationHash: 'a'.repeat(64),
|
||||
provenance: {
|
||||
sensorId: 'tof-1',
|
||||
sensorModel: 'VL53L8CH',
|
||||
firmwareVersion: '1.0.0',
|
||||
transientKind: 'raw_histogram',
|
||||
histogramPreserved: true,
|
||||
transport: 'ruview_server',
|
||||
},
|
||||
tracks: [
|
||||
{
|
||||
trackId: 'target-1',
|
||||
state: 'tracking',
|
||||
positionM: { x: 2.1, y: 1.1, z: 3.4 },
|
||||
velocityMps: { x: 0.1, y: 0, z: -0.05 },
|
||||
covarianceDiagonalM2: { x: 0.04, y: 0.06, z: 0.08 },
|
||||
confidence: 0.88,
|
||||
posteriorEntropy: 0.32,
|
||||
signalQuality: 0.81,
|
||||
modalityContributions: { lidar: 0.7, csi: 0.3 },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export type RootStackParamList = {
|
||||
|
||||
export type MainTabsParamList = {
|
||||
Live: undefined;
|
||||
NLOS: undefined;
|
||||
Vitals: undefined;
|
||||
Zones: undefined;
|
||||
MAT: undefined;
|
||||
@@ -11,6 +12,7 @@ export type MainTabsParamList = {
|
||||
};
|
||||
|
||||
export type LiveScreenParams = undefined;
|
||||
export type NLOSScreenParams = undefined;
|
||||
export type VitalsScreenParams = undefined;
|
||||
export type ZonesScreenParams = undefined;
|
||||
export type MATScreenParams = undefined;
|
||||
|
||||
100
ui/mobile/src/types/nlos.ts
Normal file
100
ui/mobile/src/types/nlos.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export const NLOS_TRACK_SCHEMA = 'ruview.nlos.track.v1' as const;
|
||||
export const NLOS_MAX_MESSAGE_BYTES = 256 * 1024;
|
||||
export const NLOS_MAX_TRACKS = 16;
|
||||
export const NLOS_MAX_EXPIRY_WINDOW_MS = 5_000;
|
||||
export const NLOS_STALE_AFTER_MS = 1_500;
|
||||
|
||||
export type NlosSource = 'live' | 'replay' | 'synthetic';
|
||||
export type NlosEvidenceLevel =
|
||||
| 'l0_synthetic'
|
||||
| 'l1_measured'
|
||||
| 'l2_calibrated'
|
||||
| 'l3_corroborated';
|
||||
export type NlosTransientKind =
|
||||
| 'raw_histogram'
|
||||
| 'compact_normalized_histogram'
|
||||
| 'depth_only'
|
||||
| 'replay';
|
||||
export type NlosTransport = 'usb_serial' | 'ruview_server' | 'replay';
|
||||
export type NlosTrackState = 'tracking' | 'degraded' | 'unknown';
|
||||
|
||||
export interface NlosVector3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface NlosModalityContributions {
|
||||
lidar: number;
|
||||
csi: number;
|
||||
}
|
||||
|
||||
export interface NlosTrack {
|
||||
trackId: string;
|
||||
state: NlosTrackState;
|
||||
positionM: NlosVector3;
|
||||
velocityMps: NlosVector3;
|
||||
covarianceDiagonalM2: NlosVector3;
|
||||
confidence: number;
|
||||
posteriorEntropy: number;
|
||||
signalQuality: number;
|
||||
modalityContributions: NlosModalityContributions;
|
||||
}
|
||||
|
||||
export interface NlosProvenance {
|
||||
sensorId: string;
|
||||
sensorModel: string;
|
||||
firmwareVersion: string;
|
||||
transientKind: NlosTransientKind;
|
||||
histogramPreserved: boolean;
|
||||
transport: NlosTransport;
|
||||
}
|
||||
|
||||
export interface NlosTrackFrame {
|
||||
schema: typeof NLOS_TRACK_SCHEMA;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
capturedAtUnixMs: number;
|
||||
expiresAtUnixMs: number;
|
||||
source: NlosSource;
|
||||
evidenceLevel: NlosEvidenceLevel;
|
||||
algorithmVersion: string;
|
||||
calibrationHash: string;
|
||||
provenance: NlosProvenance;
|
||||
tracks: NlosTrack[];
|
||||
}
|
||||
|
||||
export type NlosFreshness = 'unknown' | 'fresh' | 'stale';
|
||||
export type NlosStreamStatus =
|
||||
| 'idle'
|
||||
| 'authenticating'
|
||||
| 'connecting'
|
||||
| 'live'
|
||||
| 'synthetic_replay'
|
||||
| 'error';
|
||||
|
||||
export type NlosFrameChannel = 'authenticated_stream' | 'deterministic_replay';
|
||||
|
||||
export interface NlosFrameEvent {
|
||||
frame: NlosTrackFrame;
|
||||
channel: NlosFrameChannel;
|
||||
receivedAtUnixMs: number;
|
||||
}
|
||||
|
||||
export type NlosRejectReason =
|
||||
| 'message_too_large'
|
||||
| 'malformed_json'
|
||||
| 'invalid_schema'
|
||||
| 'invalid_shape'
|
||||
| 'invalid_bounds'
|
||||
| 'invalid_provenance'
|
||||
| 'expired'
|
||||
| 'future_frame'
|
||||
| 'session_mismatch'
|
||||
| 'out_of_order'
|
||||
| 'unauthenticated'
|
||||
| 'unsupported_binary';
|
||||
|
||||
export type NlosValidationResult =
|
||||
| { ok: true; value: NlosTrackFrame }
|
||||
| { ok: false; reason: NlosRejectReason };
|
||||
65
ui/mobile/src/utils/nlosServerUrl.ts
Normal file
65
ui/mobile/src/utils/nlosServerUrl.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface NlosServerUrlValidation {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
normalized?: string;
|
||||
}
|
||||
|
||||
// Hermes does not provide TextEncoder in every supported React Native build.
|
||||
// Count UTF-8 bytes without allocating an encoded copy.
|
||||
const utf8Length = (value: string): number => {
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code < 0x80) bytes += 1;
|
||||
else if (code < 0x800) bytes += 2;
|
||||
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
index += 1;
|
||||
} else bytes += 3;
|
||||
} else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const isLoopback = (hostname: string): boolean =>
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]' ||
|
||||
hostname === '::1';
|
||||
|
||||
/** Validate and reduce an NLOS endpoint to an origin-only URL before storage. */
|
||||
export const normalizeNlosServerUrl = (raw: string): NlosServerUrlValidation => {
|
||||
const value = raw.trim();
|
||||
if (!value || utf8Length(value) > 2_048) {
|
||||
return { valid: false, error: 'NLOS server URL must be 1 to 2048 bytes.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const secure = url.protocol === 'https:';
|
||||
const loopbackDevelopment = url.protocol === 'http:' && isLoopback(url.hostname);
|
||||
if (!secure && !loopbackDevelopment) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'NLOS requires HTTPS, except for a loopback development server.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
(url.pathname !== '' && url.pathname !== '/')
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Store only the NLOS server origin; credentials, paths, queries, and fragments are forbidden.',
|
||||
};
|
||||
}
|
||||
return { valid: true, normalized: url.origin };
|
||||
} catch {
|
||||
return { valid: false, error: 'Enter a valid NLOS server origin.' };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user