feat(nlos): add consumer transient sensing pipeline

This commit is contained in:
rUv
2026-08-22 20:46:26 -04:00
parent 0df48df7b2
commit a373ff666b
100 changed files with 16218 additions and 6698 deletions

View 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,
};

View File

@@ -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', () => {

View 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();
});
});

View File

@@ -25,6 +25,7 @@ describe('SettingsScreen', () => {
beforeEach(() => {
useSettingsStore.setState({
serverUrl: 'http://localhost:3000',
nlosServerUrl: 'http://localhost:3000',
rssiScanEnabled: false,
theme: 'system',
alertSoundEnabled: true,

View File

@@ -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();

View File

@@ -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' }),

View 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);
});
});

View 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',
});
});
});

View File

@@ -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

View 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');
});
});

View File

@@ -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);

View File

@@ -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,

View 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,
};
};

View File

@@ -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 },

View File

@@ -4,6 +4,7 @@ export type RootStackParamList = {
export type MainTabsParamList = {
Live: undefined;
NLOS: undefined;
Vitals: undefined;
Zones: undefined;
MAT: undefined;

View File

@@ -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);

View File

@@ -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;

View 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';

View 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 },
});

View 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 },
});

View 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>
);
};

View File

@@ -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 }}>

View 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();

View 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' };
}
};

View 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),
}));

View File

@@ -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 });
},

View 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,
});

View File

@@ -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
View 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 };

View 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.' };
}
};