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

5
ui/ios-nlos/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.build/
.swiftpm/
DerivedData/
xcuserdata/
*.xcuserstate

View File

@@ -0,0 +1,147 @@
import Combine
import Foundation
import RuViewNLOSApple
import RuViewNLOSCore
@MainActor
final class AppModel: ObservableObject {
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case blocked
}
@Published var endpointText = "" {
didSet {
if endpointText != oldValue {
storedTokenAvailable = false
}
}
}
@Published var pairingToken = ""
@Published private(set) var storedTokenAvailable = false
@Published private(set) var transportActive = false
@Published private(set) var connectionState: ConnectionState = .disconnected
@Published private(set) var statusMessage = "Disconnected; no track evidence is displayed."
@Published private(set) var frame: TrackDisplayFrame?
let capabilities = AppleCapabilityProbe.probe()
private let client = NLOSWebSocketClient()
private let tokenStore = KeychainPairingTokenStore()
init() {
client.onEvent = { [weak self] event in
self?.handle(event)
}
}
var tracks: [NLOSTrack] { frame?.tracks ?? [] }
var isConnected: Bool { transportActive }
func connect() {
if transportActive {
client.disconnect()
}
let trimmedEndpoint = endpointText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let endpoint = URL(string: trimmedEndpoint) else {
block("Enter a valid wss endpoint.")
return
}
do {
let enteredToken = pairingToken
let token: String
if enteredToken.isEmpty {
guard let stored = try tokenStore.load(for: endpoint) else {
storedTokenAvailable = false
block("Enter a pairing token. None is stored for this server authority.")
return
}
storedTokenAvailable = true
token = stored
} else {
token = enteredToken
}
try WSSConnectionValidator.validate(endpoint: endpoint, pairingToken: token)
if !enteredToken.isEmpty {
try tokenStore.save(enteredToken, for: endpoint)
storedTokenAvailable = true
}
try client.connect(endpoint: endpoint, pairingToken: token)
pairingToken = ""
} catch let error as LocalizedError {
block(error.errorDescription ?? "Connection setup failed closed.")
} catch {
block("Connection setup failed closed.")
}
}
func disconnect() {
client.disconnect()
}
func suspendForPrivacy() {
guard isConnected || frame != nil else { return }
client.disconnect()
}
func forgetPairingToken() {
do {
if transportActive {
client.disconnect()
}
try tokenStore.deleteAll()
pairingToken = ""
storedTokenAvailable = false
statusMessage = "Pairing token removed from Keychain."
} catch {
block("Keychain token could not be removed.")
}
}
private func handle(_ event: NLOSStreamEvent) {
switch event {
case .connecting:
transportActive = true
connectionState = .connecting
clearFrame()
statusMessage = "Opening authenticated secure stream…"
case .connected:
transportActive = true
connectionState = .connected
statusMessage = "Secure stream connected; waiting for validated evidence."
case let .frame(displayFrame):
transportActive = true
connectionState = .connected
frame = displayFrame
if displayFrame.tracks.isEmpty {
statusMessage = "Frame accepted, but no displayable tracks were present. Unknown tracks remain hidden."
} else {
statusMessage = "Validated frame \(displayFrame.sequence) with \(displayFrame.tracks.count) displayable track(s)."
}
case let .failClosed(reason):
connectionState = .blocked
clearFrame()
statusMessage = reason
case let .disconnected(reason):
transportActive = false
connectionState = .disconnected
clearFrame()
statusMessage = reason
}
}
private func block(_ reason: String) {
transportActive = false
connectionState = .blocked
clearFrame()
statusMessage = reason
}
private func clearFrame() {
frame = nil
}
}

View File

@@ -0,0 +1,256 @@
import Foundation
import RuViewNLOSApple
import RuViewNLOSCore
import SwiftUI
struct ContentView: View {
@ObservedObject var model: AppModel
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 18) {
connectionCard
statusCard
visualizationCard
capabilityCard
boundaryCard
}
.padding()
}
.navigationTitle("RuView NLOS")
.background(Color(uiColor: .systemGroupedBackground))
.onChange(of: scenePhase) { phase in
if phase != .active {
model.suspendForPrivacy()
}
}
}
}
private var connectionCard: some View {
card(title: "Authenticated stream") {
TextField("wss://host.example/api/v1/nlos/ws", text: $model.endpointText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.textContentType(.URL)
.padding(12)
.background(Color(uiColor: .tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
SecureField(
model.storedTokenAvailable ? "Pairing token stored in Keychain" : "Pairing token",
text: $model.pairingToken
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textContentType(.password)
.padding(12)
.background(Color(uiColor: .tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
HStack {
Button(model.isConnected ? "Reconnect" : "Connect") {
model.connect()
}
.buttonStyle(.borderedProminent)
if model.isConnected {
Button("Disconnect", role: .cancel) {
model.disconnect()
}
.buttonStyle(.bordered)
}
Spacer()
if model.storedTokenAvailable {
Button("Forget token", role: .destructive) {
model.forgetPairingToken()
}
.font(.caption)
}
}
Label(
"Bearer token stays in this device's Keychain and is sent only over wss.",
systemImage: "lock.shield"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var statusCard: some View {
card(title: "Evidence status") {
HStack(alignment: .top) {
Circle()
.fill(statusColor)
.frame(width: 10, height: 10)
.padding(.top, 4)
Text(model.statusMessage)
.font(.subheadline)
Spacer()
}
if let frame = model.frame {
HStack(spacing: 8) {
badge(frame.source.rawValue.uppercased(), color: sourceColor(frame.source))
badge(frame.evidenceLevel.rawValue.uppercased(), color: .indigo)
badge("SEQ \(frame.sequence)", color: .gray)
}
.accessibilityElement(children: .combine)
VStack(alignment: .leading, spacing: 4) {
Text("Sensor: \(frame.provenance.sensorModel)")
Text("Transient: \(frame.provenance.transientKind.rawValue)")
Text("Histogram preserved: \(frame.provenance.histogramPreserved ? "yes" : "no")")
Text("Algorithm: \(frame.algorithmVersion)")
}
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
private var visualizationCard: some View {
card(title: "Validated hidden target hypotheses") {
ZStack {
TrackCanvas(tracks: model.tracks)
.frame(height: 300)
.privacySensitive()
if let watermark = model.frame?.watermark {
Text(watermark)
.font(.system(size: 38, weight: .black, design: .rounded))
.foregroundStyle(.orange.opacity(0.42))
.rotationEffect(.degrees(-18))
.accessibilityLabel("Synthetic evidence watermark")
}
if model.tracks.isEmpty {
Text("NO DISPLAYABLE TRACKS")
.font(.caption.bold().monospaced())
.foregroundStyle(.secondary)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
}
}
ForEach(model.tracks) { track in
HStack {
VStack(alignment: .leading) {
Text(track.trackId)
.font(.subheadline.monospaced())
.lineLimit(1)
Text(String(
format: "x %.2f y %.2f z %.2f m",
track.positionM.x,
track.positionM.y,
track.positionM.z
))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
Spacer()
Text("\(Int(track.confidence * 100))%")
.font(.headline.monospacedDigit())
badge(track.state.rawValue.uppercased(), color: track.state == .degraded ? .orange : .cyan)
}
.accessibilityElement(children: .combine)
}
}
.privacySensitive()
}
private var capabilityCard: some View {
card(title: "Apple capability probe") {
capabilityRow("ARKit scene depth", model.capabilities.sceneDepth)
capabilityRow("ARKit smoothed depth", model.capabilities.smoothedSceneDepth)
capabilityRow("ARKit scene mesh", model.capabilities.sceneMesh)
capabilityRow("ARKit world pose", model.capabilities.worldPose)
capabilityRow("Raw photon histograms", model.capabilities.rawPhotonHistograms)
Text(model.capabilities.rawPhotonHistogramReason)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var boundaryCard: some View {
card(title: "Interpretation boundary") {
Label(
"This client visualizes validated NLOS output produced by an external transient histogram pipeline.",
systemImage: "waveform.path.ecg.rectangle"
)
Label(
"ARKit depth, mesh, and pose are useful context, but this app never labels them as optical NLOS evidence.",
systemImage: "exclamationmark.shield"
)
Text("Unknown, stale, malformed, replayed, oversized, or unauthenticated input is hidden by default.")
.font(.caption.bold())
}
.font(.subheadline)
}
private var statusColor: Color {
switch model.connectionState {
case .connected: return .green
case .connecting: return .yellow
case .blocked: return .red
case .disconnected: return .secondary
}
}
private func sourceColor(_ source: NLOSSource) -> Color {
switch source {
case .live: return .green
case .replay: return .blue
case .synthetic: return .orange
}
}
private func capabilityRow(
_ title: String,
_ availability: AppleCapabilityAvailability
) -> some View {
HStack {
Text(title)
Spacer()
Label(
availability.rawValue.capitalized,
systemImage: availability == .available ? "checkmark.circle.fill" : "xmark.circle.fill"
)
.foregroundStyle(availability == .available ? .green : .secondary)
}
.font(.subheadline)
}
private func badge(_ text: String, color: Color) -> some View {
Text(text)
.font(.caption2.bold().monospaced())
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.14), in: Capsule())
.foregroundStyle(color)
}
private func card<Content: View>(
title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.headline)
content()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>RuView NLOS</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>RuView connects to an explicitly configured, authenticated NLOS processing server on your network.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
import SwiftUI
@main
struct RuViewNLOSApp: App {
@StateObject private var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView(model: model)
}
}
}

View File

@@ -0,0 +1,81 @@
import Foundation
import RuViewNLOSCore
import SwiftUI
struct TrackCanvas: View {
let tracks: [NLOSTrack]
var body: some View {
Canvas { context, size in
drawGrid(context: &context, size: size)
let radiusMeters = max(
5,
min(100, tracks.flatMap { [abs($0.positionM.x), abs($0.positionM.z)] }.max() ?? 5)
)
for track in tracks {
let point = CGPoint(
x: size.width / 2 + CGFloat(track.positionM.x / radiusMeters) * size.width * 0.45,
y: size.height / 2 - CGFloat(track.positionM.z / radiusMeters) * size.height * 0.45
)
let uncertainty = min(
34,
max(8, CGFloat(sqrt(max(track.covarianceDiagonalM2.x, track.covarianceDiagonalM2.z))) * 14)
)
let color: Color = track.state == .degraded ? .orange : .cyan
let uncertaintyRect = CGRect(
x: point.x - uncertainty,
y: point.y - uncertainty,
width: uncertainty * 2,
height: uncertainty * 2
)
context.stroke(
Path(ellipseIn: uncertaintyRect),
with: .color(color.opacity(0.45)),
lineWidth: 1
)
context.fill(
Path(ellipseIn: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10)),
with: .color(color)
)
context.draw(
Text(String(track.trackId.prefix(12)))
.font(.caption2.monospaced())
.foregroundColor(.primary),
at: CGPoint(x: point.x, y: point.y + uncertainty + 10)
)
}
}
.background(Color(uiColor: .secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
}
.accessibilityHidden(true)
}
private func drawGrid(context: inout GraphicsContext, size: CGSize) {
var path = Path()
for fraction in stride(
from: CGFloat(0.1),
through: CGFloat(0.9),
by: CGFloat(0.1)
) {
let x = size.width * fraction
let y = size.height * fraction
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: size.width, y: y))
}
context.stroke(path, with: .color(.secondary.opacity(0.12)), lineWidth: 0.5)
var axes = Path()
axes.move(to: CGPoint(x: size.width / 2, y: 0))
axes.addLine(to: CGPoint(x: size.width / 2, y: size.height))
axes.move(to: CGPoint(x: 0, y: size.height / 2))
axes.addLine(to: CGPoint(x: size.width, y: size.height / 2))
context.stroke(axes, with: .color(.secondary.opacity(0.5)), lineWidth: 1)
}
}

30
ui/ios-nlos/Package.swift Normal file
View File

@@ -0,0 +1,30 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "RuViewNLOS",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "RuViewNLOSCore", targets: ["RuViewNLOSCore"]),
.library(name: "RuViewNLOSApple", targets: ["RuViewNLOSApple"]),
],
targets: [
.target(name: "RuViewNLOSCore"),
.target(
name: "RuViewNLOSApple",
dependencies: ["RuViewNLOSCore"]
),
.testTarget(
name: "RuViewNLOSCoreTests",
dependencies: ["RuViewNLOSCore"]
),
.testTarget(
name: "RuViewNLOSAppleTests",
dependencies: ["RuViewNLOSApple"]
),
]
)

85
ui/ios-nlos/README.md Normal file
View File

@@ -0,0 +1,85 @@
# RuView NLOS for iOS
RuView NLOS is a native SwiftUI monitor for authenticated hidden target hypotheses produced by the RuView consumer time of flight pipeline. It is deliberately a display and transport adapter. It does not claim that Apple LiDAR or ARKit can perform optical non line of sight reconstruction.
The package has two libraries:
| Product | Responsibility |
|---|---|
| `RuViewNLOSCore` | Typed wire model, strict validation, freshness policy, sequence and replay guard, secure endpoint validation |
| `RuViewNLOSApple` | Apple capability probe, Keychain credential storage, authenticated WebSocket transport |
`RuViewNLOS.xcodeproj` contains the directly buildable iOS SwiftUI app and links both local package products.
## Evidence boundary
The app consumes JSON envelopes with schema `ruview.nlos.track.v1`. A frame is displayed only after the following checks pass:
1. The UTF 8 JSON frame is no larger than 256 KiB and has at most 16 unique tracks.
2. Every object has exactly the versioned keys. The session identifier, interoperable sequence, algorithm version, provenance strings, hashes, timestamps, vectors, covariance, confidence, entropy, signal quality, and modality contributions are bounded.
3. The expiry is after capture, no more than 5 seconds after capture, still in the future, and capture is no more than 1 second ahead of the local clock.
4. A connection binds to one session and each sequence must increase. A session change requires an explicit reconnect.
5. Live evidence must be at least `l1_measured`, must preserve a raw or compact normalized transient histogram, and cannot use replay transport.
6. `depth_only` provenance can never enter the live NLOS display path.
7. Synthetic evidence must be `l0_synthetic`, use the all zero calibration hash and replay transport, and receives a persistent `SYNTHETIC` watermark.
8. Tracks whose state is `unknown` are never displayed. A stale, malformed, oversized, replayed, or unsupported frame clears the entire current display.
The client also schedules a local expiry for the last accepted frame. If the stream stalls without closing, the visualization is cleared at the envelope deadline. Decode and sequence processing run on a dedicated Swift actor, while the main actor receives only the newest bounded display frame.
## Apple capability boundary
The native probe reports these capabilities separately:
| Apple signal | Public API status | NLOS interpretation |
|---|---|---|
| Scene depth | Probed with `ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth)` | Derived visible surface depth only |
| Smoothed scene depth | Probed with `.smoothedSceneDepth` | Derived visible surface depth only |
| Scene mesh | Probed with `supportsSceneReconstruction(.mesh)` | Visible environment geometry only |
| World pose | Probed with `ARWorldTrackingConfiguration.isSupported` | Motion and registration context only |
| Raw photon timing histograms | Reported unavailable | Required from an external supported transient sensor for this pipeline |
The app never upgrades ARKit depth, mesh, or pose into optical NLOS evidence. The current monitor performs only static capability checks, does not start an `ARSession`, and does not request camera permission.
## Security model
Only `wss` endpoints are accepted. URLs containing embedded user credentials or fragments are rejected, and HTTP redirects are not followed. Pairing tokens must be 32 to 512 visible ASCII characters, are sent in the `Authorization: Bearer` header, and are never placed in the URL or frame body.
On Apple platforms, the token is stored as a generic password with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. It is not written to `UserDefaults`, logs, source, fixtures, or crash messages. The URL session is ephemeral with cookies and caches disabled. A production endpoint needs a certificate trusted by iOS; this client does not bypass TLS validation or accept self signed certificates.
The visualization is advisory. It must not directly trigger physical actuation or safety critical decisions.
The app has no analytics or position telemetry and its privacy manifest declares no tracking or collected data. Track frames remain in memory only and are replaced by the newest valid frame. Leaving the active foreground disconnects the stream and clears track state; the visualization is also marked privacy sensitive for system snapshots.
## Build and test
Requirements:
1. Swift 5.9 or newer for the package tests.
2. Xcode 15 or newer for the iOS app.
3. iOS 16 or newer for deployment.
Run the deterministic protocol and security tests on macOS or Linux:
```bash
cd ui/ios-nlos
swift test
```
Build the unsigned simulator app on macOS:
```bash
cd ui/ios-nlos
xcodebuild \
-project RuViewNLOS.xcodeproj \
-scheme RuViewNLOS \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO \
build
```
For a physical iPhone, open `RuViewNLOS.xcodeproj`, choose a development team and a unique bundle identifier, then build to the device. Enter an explicitly provisioned `wss` track endpoint and pairing token. Do not put the token in the endpoint query string.
## Validation limits
A successful Swift test or simulator build is software evidence only. It is not evidence that Apple hardware exposes photon timing histograms and it is not a reproduction of the MIT consumer NLOS result. Real hardware validation requires both an external supported time of flight sensor and captured RuView server output with reviewed calibration and provenance. The simulator normally reports ARKit sensor capabilities as unavailable.

View File

@@ -0,0 +1,357 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
A00000000000000000000001 /* RuViewNLOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000001 /* RuViewNLOSApp.swift */; };
A00000000000000000000002 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000002 /* AppModel.swift */; };
A00000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000003 /* ContentView.swift */; };
A00000000000000000000004 /* TrackCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000004 /* TrackCanvas.swift */; };
A00000000000000000000005 /* RuViewNLOSCore in Frameworks */ = {isa = PBXBuildFile; productRef = J00000000000000000000001 /* RuViewNLOSCore */; };
A00000000000000000000006 /* RuViewNLOSApple in Frameworks */ = {isa = PBXBuildFile; productRef = J00000000000000000000002 /* RuViewNLOSApple */; };
A00000000000000000000007 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000007 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
B00000000000000000000001 /* RuViewNLOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuViewNLOSApp.swift; sourceTree = "<group>"; };
B00000000000000000000002 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
B00000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B00000000000000000000004 /* TrackCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackCanvas.swift; sourceTree = "<group>"; };
B00000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B00000000000000000000006 /* RuViewNLOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RuViewNLOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
B00000000000000000000007 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
D00000000000000000000002 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A00000000000000000000005 /* RuViewNLOSCore in Frameworks */,
A00000000000000000000006 /* RuViewNLOSApple in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
C00000000000000000000000 = {
isa = PBXGroup;
children = (
C00000000000000000000001 /* App */,
C00000000000000000000002 /* Products */,
);
sourceTree = "<group>";
};
C00000000000000000000001 /* App */ = {
isa = PBXGroup;
children = (
B00000000000000000000001 /* RuViewNLOSApp.swift */,
B00000000000000000000002 /* AppModel.swift */,
B00000000000000000000003 /* ContentView.swift */,
B00000000000000000000004 /* TrackCanvas.swift */,
B00000000000000000000005 /* Info.plist */,
B00000000000000000000007 /* PrivacyInfo.xcprivacy */,
);
path = App;
sourceTree = "<group>";
};
C00000000000000000000002 /* Products */ = {
isa = PBXGroup;
children = (
B00000000000000000000006 /* RuViewNLOS.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
E00000000000000000000001 /* RuViewNLOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = H00000000000000000000001 /* Build configuration list for PBXNativeTarget "RuViewNLOS" */;
buildPhases = (
D00000000000000000000001 /* Sources */,
D00000000000000000000002 /* Frameworks */,
D00000000000000000000003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = RuViewNLOS;
packageProductDependencies = (
J00000000000000000000001 /* RuViewNLOSCore */,
J00000000000000000000002 /* RuViewNLOSApple */,
);
productName = RuViewNLOS;
productReference = B00000000000000000000006 /* RuViewNLOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
F00000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
E00000000000000000000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = H00000000000000000000002 /* Build configuration list for PBXProject "RuViewNLOS" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = C00000000000000000000000;
packageReferences = (
I00000000000000000000001 /* XCLocalSwiftPackageReference "." */,
);
productRefGroup = C00000000000000000000002 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
E00000000000000000000001 /* RuViewNLOS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
D00000000000000000000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A00000000000000000000007 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
D00000000000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A00000000000000000000001 /* RuViewNLOSApp.swift in Sources */,
A00000000000000000000002 /* AppModel.swift in Sources */,
A00000000000000000000003 /* ContentView.swift in Sources */,
A00000000000000000000004 /* TrackCanvas.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
G00000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = org.ruvnet.RuViewNLOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
G00000000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = org.ruvnet.RuViewNLOS;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
G00000000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
G00000000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
H00000000000000000000001 /* Build configuration list for PBXNativeTarget "RuViewNLOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
G00000000000000000000001 /* Debug */,
G00000000000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
H00000000000000000000002 /* Build configuration list for PBXProject "RuViewNLOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
G00000000000000000000003 /* Debug */,
G00000000000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
I00000000000000000000001 /* XCLocalSwiftPackageReference "." */ = {
isa = XCLocalSwiftPackageReference;
relativePath = .;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
J00000000000000000000001 /* RuViewNLOSCore */ = {
isa = XCSwiftPackageProductDependency;
package = I00000000000000000000001 /* XCLocalSwiftPackageReference "." */;
productName = RuViewNLOSCore;
};
J00000000000000000000002 /* RuViewNLOSApple */ = {
isa = XCSwiftPackageProductDependency;
package = I00000000000000000000001 /* XCLocalSwiftPackageReference "." */;
productName = RuViewNLOSApple;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = F00000000000000000000001 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E00000000000000000000001"
BuildableName = "RuViewNLOS.app"
BlueprintName = "RuViewNLOS"
ReferencedContainer = "container:RuViewNLOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E00000000000000000000001"
BuildableName = "RuViewNLOS.app"
BlueprintName = "RuViewNLOS"
ReferencedContainer = "container:RuViewNLOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E00000000000000000000001"
BuildableName = "RuViewNLOS.app"
BlueprintName = "RuViewNLOS"
ReferencedContainer = "container:RuViewNLOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,65 @@
import Foundation
import RuViewNLOSCore
#if canImport(ARKit)
import ARKit
#endif
public enum AppleCapabilityAvailability: String, Equatable, Sendable {
case available
case unavailable
}
public struct AppleNLOSCapabilityReport: Equatable, Sendable {
public let sceneDepth: AppleCapabilityAvailability
public let smoothedSceneDepth: AppleCapabilityAvailability
public let sceneMesh: AppleCapabilityAvailability
public let worldPose: AppleCapabilityAvailability
public let rawPhotonHistograms: AppleCapabilityAvailability
public let rawPhotonHistogramReason: String
public init(
sceneDepth: AppleCapabilityAvailability,
smoothedSceneDepth: AppleCapabilityAvailability,
sceneMesh: AppleCapabilityAvailability,
worldPose: AppleCapabilityAvailability,
rawPhotonHistograms: AppleCapabilityAvailability,
rawPhotonHistogramReason: String
) {
self.sceneDepth = sceneDepth
self.smoothedSceneDepth = smoothedSceneDepth
self.sceneMesh = sceneMesh
self.worldPose = worldPose
self.rawPhotonHistograms = rawPhotonHistograms
self.rawPhotonHistogramReason = rawPhotonHistogramReason
}
}
public enum AppleCapabilityProbe {
public static func probe() -> AppleNLOSCapabilityReport {
#if canImport(ARKit)
let sceneDepth = ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth)
let smoothedDepth = ARWorldTrackingConfiguration.supportsFrameSemantics(.smoothedSceneDepth)
let mesh = ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh)
let pose = ARWorldTrackingConfiguration.isSupported
return AppleNLOSCapabilityReport(
sceneDepth: sceneDepth ? .available : .unavailable,
smoothedSceneDepth: smoothedDepth ? .available : .unavailable,
sceneMesh: mesh ? .available : .unavailable,
worldPose: pose ? .available : .unavailable,
rawPhotonHistograms: .unavailable,
rawPhotonHistogramReason: "Public ARKit APIs expose derived depth, mesh, and pose, not the raw per-zone photon timing histograms required by this NLOS pipeline."
)
#else
return AppleNLOSCapabilityReport(
sceneDepth: .unavailable,
smoothedSceneDepth: .unavailable,
sceneMesh: .unavailable,
worldPose: .unavailable,
rawPhotonHistograms: .unavailable,
rawPhotonHistogramReason: "ARKit is unavailable on this build host. Raw photon histograms are not exposed by public Apple APIs."
)
#endif
}
}

View File

@@ -0,0 +1,120 @@
import Foundation
import RuViewNLOSCore
#if canImport(Security)
import Security
public enum PairingTokenStoreError: Error, LocalizedError, Sendable {
case keychainFailure(OSStatus)
case invalidStoredValue
public var errorDescription: String? {
switch self {
case .keychainFailure:
return "The pairing token could not be accessed in Keychain."
case .invalidStoredValue:
return "The stored pairing token is invalid."
}
}
}
public final class KeychainPairingTokenStore: @unchecked Sendable {
private let service: String
public init(service: String = "org.ruvnet.RuViewNLOS.pairing") {
self.service = service
}
public func save(_ token: String, for endpoint: URL) throws {
try WSSConnectionValidator.validatePairingToken(token)
let account = try WSSConnectionValidator.credentialAccount(for: endpoint)
guard let data = token.data(using: .utf8) else {
throw PairingTokenStoreError.invalidStoredValue
}
let identity: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
let updateStatus = SecItemUpdate(identity as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw PairingTokenStoreError.keychainFailure(updateStatus)
}
var insert = identity
attributes.forEach { insert[$0.key] = $0.value }
let insertStatus = SecItemAdd(insert as CFDictionary, nil)
guard insertStatus == errSecSuccess else {
throw PairingTokenStoreError.keychainFailure(insertStatus)
}
}
public func load(for endpoint: URL) throws -> String? {
let account = try WSSConnectionValidator.credentialAccount(for: endpoint)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = item as? Data,
let token = String(data: data, encoding: .utf8) else {
if status == errSecSuccess {
throw PairingTokenStoreError.invalidStoredValue
}
throw PairingTokenStoreError.keychainFailure(status)
}
do {
try WSSConnectionValidator.validatePairingToken(token)
} catch {
throw PairingTokenStoreError.invalidStoredValue
}
return token
}
public func deleteAll() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw PairingTokenStoreError.keychainFailure(status)
}
}
}
#else
public enum PairingTokenStoreError: Error, LocalizedError, Sendable {
case unavailable
public var errorDescription: String? {
"Apple Keychain is unavailable on this platform."
}
}
public final class KeychainPairingTokenStore: @unchecked Sendable {
public init(service: String = "org.ruvnet.RuViewNLOS.pairing") {}
public func save(_ token: String, for endpoint: URL) throws {
throw PairingTokenStoreError.unavailable
}
public func load(for endpoint: URL) throws -> String? {
throw PairingTokenStoreError.unavailable
}
public func deleteAll() throws { throw PairingTokenStoreError.unavailable }
}
#endif

View File

@@ -0,0 +1,284 @@
import Foundation
import RuViewNLOSCore
public enum NLOSStreamEvent: Sendable {
case connecting
case connected
case frame(TrackDisplayFrame)
case failClosed(String)
case disconnected(String)
}
#if canImport(Darwin)
private final class RejectRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}
private enum ProcessedMessage: Sendable {
case authenticated(NLOSAuthenticatedSession)
case frame(TrackDisplayFrame)
}
private actor FrameProcessor {
private let decoder = TrackEnvelopeDecoder()
private var streamGuard = TrackStreamGuard()
private var authenticatedSession: NLOSAuthenticatedSession?
func process(_ data: Data, nowUnixMs: UInt64) throws -> ProcessedMessage {
guard let authenticatedSession else {
let authenticated = try decoder.decodeAuthenticated(
data,
nowUnixMs: nowUnixMs
)
self.authenticatedSession = authenticated
return .authenticated(authenticated)
}
guard authenticatedSession.expiresAtUnixMs > nowUnixMs else {
throw NLOSValidationError.staleFrame
}
let envelope = try decoder.decode(data, nowUnixMs: nowUnixMs)
guard envelope.value.sessionId == authenticatedSession.sessionId else {
throw NLOSValidationError.sessionChanged
}
return .frame(try streamGuard.accept(envelope, nowUnixMs: nowUnixMs))
}
}
@MainActor
public final class NLOSWebSocketClient {
public var onEvent: ((NLOSStreamEvent) -> Void)?
private var session: URLSession?
private var redirectDelegate: RejectRedirectDelegate?
private var socket: URLSessionWebSocketTask?
private var receiveTask: Task<Void, Never>?
private var expiryTask: Task<Void, Never>?
private var authenticationTask: Task<Void, Never>?
private var sessionExpiryTask: Task<Void, Never>?
private var connectionId: UUID?
public init() {}
deinit {
receiveTask?.cancel()
expiryTask?.cancel()
authenticationTask?.cancel()
sessionExpiryTask?.cancel()
socket?.cancel(with: .goingAway, reason: nil)
session?.invalidateAndCancel()
}
public func connect(endpoint: URL, pairingToken: String) throws {
try WSSConnectionValidator.validate(endpoint: endpoint, pairingToken: pairingToken)
disconnect(emitEvent: false)
let currentConnectionId = UUID()
let processor = FrameProcessor()
connectionId = currentConnectionId
let configuration = URLSessionConfiguration.ephemeral
configuration.urlCache = nil
configuration.httpCookieStorage = nil
configuration.httpShouldSetCookies = false
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 86_400
let redirectDelegate = RejectRedirectDelegate()
let session = URLSession(
configuration: configuration,
delegate: redirectDelegate,
delegateQueue: nil
)
var request = URLRequest(url: endpoint)
request.timeoutInterval = 15
request.setValue("Bearer \(pairingToken)", forHTTPHeaderField: "Authorization")
request.setValue(TrackEnvelopeDecoder.schema, forHTTPHeaderField: "Sec-WebSocket-Protocol")
let socket = session.webSocketTask(with: request)
socket.maximumMessageSize = TrackEnvelopeDecoder.maximumFrameBytes
self.session = session
self.redirectDelegate = redirectDelegate
self.socket = socket
onEvent?(.connecting)
socket.resume()
authenticationTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
return
}
guard let self, self.connectionId == currentConnectionId else { return }
self.failClosed("Authentication acknowledgement timed out; all tracks were hidden.")
self.disconnect(emitEvent: true)
}
receiveTask = Task { [weak self, weak socket] in
guard let socket else { return }
while !Task.isCancelled {
do {
let message = try await socket.receive()
guard let self, self.connectionId == currentConnectionId else { return }
await self.handle(
message,
connectionId: currentConnectionId,
processor: processor
)
} catch {
guard !Task.isCancelled, let self,
self.connectionId == currentConnectionId else { return }
self.failClosed("Secure stream ended; all tracks were hidden.")
self.disconnect(emitEvent: true)
return
}
}
}
}
public func disconnect() {
disconnect(emitEvent: true)
}
private func disconnect(emitEvent: Bool) {
connectionId = nil
receiveTask?.cancel()
receiveTask = nil
expiryTask?.cancel()
expiryTask = nil
authenticationTask?.cancel()
authenticationTask = nil
sessionExpiryTask?.cancel()
sessionExpiryTask = nil
socket?.cancel(with: .normalClosure, reason: nil)
socket = nil
session?.invalidateAndCancel()
session = nil
redirectDelegate = nil
if emitEvent {
onEvent?(.disconnected("Disconnected; all tracks are hidden."))
}
}
private func handle(
_ message: URLSessionWebSocketTask.Message,
connectionId: UUID,
processor: FrameProcessor
) async {
let data: Data
switch message {
case let .data(binary):
data = binary
case let .string(text):
guard let encoded = text.data(using: .utf8) else {
failClosed("A non UTF-8 frame was rejected.")
return
}
data = encoded
@unknown default:
failClosed("An unsupported WebSocket frame was rejected.")
return
}
let nowUnixMs = Self.nowUnixMs()
do {
let processed = try await processor.process(data, nowUnixMs: nowUnixMs)
guard self.connectionId == connectionId else { return }
guard case let .frame(displayFrame) = processed else {
guard case let .authenticated(session) = processed else { return }
authenticationTask?.cancel()
authenticationTask = nil
scheduleSessionExpiry(session, connectionId: connectionId)
onEvent?(.connected)
return
}
guard displayFrame.expiresAtUnixMs > Self.nowUnixMs() else {
throw NLOSValidationError.staleFrame
}
onEvent?(.frame(displayFrame))
scheduleExpiry(for: displayFrame, connectionId: connectionId)
} catch let validationError as NLOSValidationError {
failClosed(validationError.localizedDescription)
} catch {
failClosed("Frame validation failed; all tracks were hidden.")
}
}
private func scheduleExpiry(for frame: TrackDisplayFrame, connectionId: UUID) {
expiryTask?.cancel()
let now = Self.nowUnixMs()
let delayMs = frame.expiresAtUnixMs > now ? frame.expiresAtUnixMs - now : 0
let sequence = frame.sequence
let sessionId = frame.sessionId
expiryTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: delayMs * 1_000_000)
} catch {
return
}
guard let self, self.connectionId == connectionId else { return }
self.onEvent?(.failClosed(
"Frame \(sessionId)#\(sequence) expired; all tracks were hidden."
))
}
}
private func scheduleSessionExpiry(
_ session: NLOSAuthenticatedSession,
connectionId: UUID
) {
sessionExpiryTask?.cancel()
let now = Self.nowUnixMs()
let delayMs = session.expiresAtUnixMs > now ? session.expiresAtUnixMs - now : 0
sessionExpiryTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: delayMs * 1_000_000)
} catch {
return
}
guard let self, self.connectionId == connectionId else { return }
self.failClosed("Authenticated session expired; all tracks were hidden.")
self.disconnect(emitEvent: true)
}
}
private func failClosed(_ reason: String) {
expiryTask?.cancel()
expiryTask = nil
onEvent?(.failClosed(reason))
}
private static func nowUnixMs() -> UInt64 {
UInt64(Date().timeIntervalSince1970 * 1_000)
}
}
#else
@MainActor
public final class NLOSWebSocketClient {
public var onEvent: ((NLOSStreamEvent) -> Void)?
public init() {}
public func connect(endpoint: URL, pairingToken: String) throws {
try WSSConnectionValidator.validate(endpoint: endpoint, pairingToken: pairingToken)
onEvent?(.failClosed("Apple URLSession WebSocket support is unavailable on this platform."))
}
public func disconnect() {
onEvent?(.disconnected("Disconnected; all tracks are hidden."))
}
}
#endif

View File

@@ -0,0 +1,319 @@
import Foundation
private struct AnyCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
}
private func requireExactKeys(_ decoder: Decoder, allowed: Set<String>) throws {
let container = try decoder.container(keyedBy: AnyCodingKey.self)
let actual = Set(container.allKeys.map(\.stringValue))
guard actual == allowed else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Object keys do not match ruview.nlos.track.v1."
)
)
}
}
public enum NLOSSource: String, Decodable, Sendable {
case live
case replay
case synthetic
}
public enum EvidenceLevel: String, Decodable, Sendable, Comparable {
case l0Synthetic = "l0_synthetic"
case l1Measured = "l1_measured"
case l2Calibrated = "l2_calibrated"
case l3Corroborated = "l3_corroborated"
private var rank: Int {
switch self {
case .l0Synthetic: return 0
case .l1Measured: return 1
case .l2Calibrated: return 2
case .l3Corroborated: return 3
}
}
public static func < (lhs: EvidenceLevel, rhs: EvidenceLevel) -> Bool {
lhs.rank < rhs.rank
}
}
public enum TransientKind: String, Decodable, Sendable {
case rawHistogram = "raw_histogram"
case compactNormalizedHistogram = "compact_normalized_histogram"
case depthOnly = "depth_only"
case replay
}
public enum NLOSTransport: String, Decodable, Sendable {
case usbSerial = "usb_serial"
case ruviewServer = "ruview_server"
case replay
}
public enum NLOSTrackState: String, Decodable, Sendable {
case tracking
case degraded
case unknown
}
public struct Vector3: Decodable, Equatable, Sendable {
public let x: Double
public let y: Double
public let z: Double
private enum CodingKeys: String, CodingKey {
case x, y, z
}
public init(from decoder: Decoder) throws {
try requireExactKeys(decoder, allowed: ["x", "y", "z"])
let container = try decoder.container(keyedBy: CodingKeys.self)
x = try container.decode(Double.self, forKey: .x)
y = try container.decode(Double.self, forKey: .y)
z = try container.decode(Double.self, forKey: .z)
}
}
public struct ModalityContributions: Decodable, Equatable, Sendable {
public let lidar: Double
public let csi: Double
private enum CodingKeys: String, CodingKey {
case lidar, csi
}
public init(from decoder: Decoder) throws {
try requireExactKeys(decoder, allowed: ["lidar", "csi"])
let container = try decoder.container(keyedBy: CodingKeys.self)
lidar = try container.decode(Double.self, forKey: .lidar)
csi = try container.decode(Double.self, forKey: .csi)
}
}
public struct NLOSProvenance: Decodable, Equatable, Sendable {
public let sensorId: String
public let sensorModel: String
public let firmwareVersion: String
public let transientKind: TransientKind
public let histogramPreserved: Bool
public let transport: NLOSTransport
private enum CodingKeys: String, CodingKey {
case sensorId
case sensorModel
case firmwareVersion
case transientKind
case histogramPreserved
case transport
}
public init(from decoder: Decoder) throws {
try requireExactKeys(
decoder,
allowed: [
"sensorId",
"sensorModel",
"firmwareVersion",
"transientKind",
"histogramPreserved",
"transport",
]
)
let container = try decoder.container(keyedBy: CodingKeys.self)
sensorId = try container.decode(String.self, forKey: .sensorId)
sensorModel = try container.decode(String.self, forKey: .sensorModel)
firmwareVersion = try container.decode(String.self, forKey: .firmwareVersion)
transientKind = try container.decode(TransientKind.self, forKey: .transientKind)
histogramPreserved = try container.decode(Bool.self, forKey: .histogramPreserved)
transport = try container.decode(NLOSTransport.self, forKey: .transport)
}
}
public struct NLOSTrack: Decodable, Equatable, Identifiable, Sendable {
public let trackId: String
public let state: NLOSTrackState
public let positionM: Vector3
public let velocityMps: Vector3
public let covarianceDiagonalM2: Vector3
public let confidence: Double
public let posteriorEntropy: Double
public let signalQuality: Double
public let modalityContributions: ModalityContributions
public var id: String { trackId }
private enum CodingKeys: String, CodingKey {
case trackId
case state
case positionM
case velocityMps
case covarianceDiagonalM2
case confidence
case posteriorEntropy
case signalQuality
case modalityContributions
}
public init(from decoder: Decoder) throws {
try requireExactKeys(
decoder,
allowed: [
"trackId",
"state",
"positionM",
"velocityMps",
"covarianceDiagonalM2",
"confidence",
"posteriorEntropy",
"signalQuality",
"modalityContributions",
]
)
let container = try decoder.container(keyedBy: CodingKeys.self)
trackId = try container.decode(String.self, forKey: .trackId)
state = try container.decode(NLOSTrackState.self, forKey: .state)
positionM = try container.decode(Vector3.self, forKey: .positionM)
velocityMps = try container.decode(Vector3.self, forKey: .velocityMps)
covarianceDiagonalM2 = try container.decode(Vector3.self, forKey: .covarianceDiagonalM2)
confidence = try container.decode(Double.self, forKey: .confidence)
posteriorEntropy = try container.decode(Double.self, forKey: .posteriorEntropy)
signalQuality = try container.decode(Double.self, forKey: .signalQuality)
modalityContributions = try container.decode(
ModalityContributions.self,
forKey: .modalityContributions
)
}
}
public struct NLOSTrackEnvelope: Decodable, Equatable, Sendable {
public let schema: String
public let sessionId: String
public let sequence: UInt64
public let capturedAtUnixMs: UInt64
public let expiresAtUnixMs: UInt64
public let source: NLOSSource
public let evidenceLevel: EvidenceLevel
public let algorithmVersion: String
public let calibrationHash: String
public let provenance: NLOSProvenance
public let tracks: [NLOSTrack]
private enum CodingKeys: String, CodingKey {
case schema
case sessionId
case sequence
case capturedAtUnixMs
case expiresAtUnixMs
case source
case evidenceLevel
case algorithmVersion
case calibrationHash
case provenance
case tracks
}
public init(from decoder: Decoder) throws {
try requireExactKeys(
decoder,
allowed: [
"schema",
"sessionId",
"sequence",
"capturedAtUnixMs",
"expiresAtUnixMs",
"source",
"evidenceLevel",
"algorithmVersion",
"calibrationHash",
"provenance",
"tracks",
]
)
let container = try decoder.container(keyedBy: CodingKeys.self)
schema = try container.decode(String.self, forKey: .schema)
sessionId = try container.decode(String.self, forKey: .sessionId)
sequence = try container.decode(UInt64.self, forKey: .sequence)
capturedAtUnixMs = try container.decode(UInt64.self, forKey: .capturedAtUnixMs)
expiresAtUnixMs = try container.decode(UInt64.self, forKey: .expiresAtUnixMs)
source = try container.decode(NLOSSource.self, forKey: .source)
evidenceLevel = try container.decode(EvidenceLevel.self, forKey: .evidenceLevel)
algorithmVersion = try container.decode(String.self, forKey: .algorithmVersion)
calibrationHash = try container.decode(String.self, forKey: .calibrationHash)
provenance = try container.decode(NLOSProvenance.self, forKey: .provenance)
tracks = try container.decode([NLOSTrack].self, forKey: .tracks)
}
}
/// First server message on every authenticated WebSocket connection.
public struct NLOSAuthenticatedSession: Decodable, Equatable, Sendable {
public let schema: String
public let sessionId: String
public let expiresAtUnixMs: UInt64
private enum CodingKeys: String, CodingKey {
case schema
case sessionId
case expiresAtUnixMs
}
public init(from decoder: Decoder) throws {
try requireExactKeys(
decoder,
allowed: ["schema", "sessionId", "expiresAtUnixMs"]
)
let container = try decoder.container(keyedBy: CodingKeys.self)
schema = try container.decode(String.self, forKey: .schema)
sessionId = try container.decode(String.self, forKey: .sessionId)
expiresAtUnixMs = try container.decode(UInt64.self, forKey: .expiresAtUnixMs)
}
}
public struct ValidatedTrackEnvelope: Equatable, Sendable {
public let value: NLOSTrackEnvelope
public var visibleTracks: [NLOSTrack] {
value.tracks.filter { $0.state != .unknown }
}
public var watermark: String? {
switch value.source {
case .synthetic: return "SYNTHETIC"
case .replay: return "REPLAY"
case .live: return nil
}
}
public func isFresh(atUnixMs nowUnixMs: UInt64) -> Bool {
value.expiresAtUnixMs > nowUnixMs
}
}
public struct TrackDisplayFrame: Equatable, Sendable {
public let sessionId: String
public let sequence: UInt64
public let capturedAtUnixMs: UInt64
public let expiresAtUnixMs: UInt64
public let source: NLOSSource
public let evidenceLevel: EvidenceLevel
public let algorithmVersion: String
public let provenance: NLOSProvenance
public let tracks: [NLOSTrack]
public let watermark: String?
}

View File

@@ -0,0 +1,282 @@
import Foundation
public enum NLOSValidationError: Error, Equatable, LocalizedError, Sendable {
case frameTooLarge(actualBytes: Int, maximumBytes: Int)
case malformedEnvelope
case invalidField(String)
case staleFrame
case futureDatedFrame
case excessiveLifetime
case replayedSequence
case sessionChanged
case insecureEndpoint
case invalidPairingToken
public var errorDescription: String? {
switch self {
case let .frameTooLarge(actualBytes, maximumBytes):
return "Frame is \(actualBytes) bytes; the limit is \(maximumBytes) bytes."
case .malformedEnvelope:
return "Frame is not a valid ruview.nlos.track.v1 envelope."
case let .invalidField(field):
return "Frame failed validation for \(field)."
case .staleFrame:
return "Frame is stale and was hidden."
case .futureDatedFrame:
return "Frame timestamp is outside the allowed clock skew."
case .excessiveLifetime:
return "Frame lifetime exceeds the 5 second safety window."
case .replayedSequence:
return "Frame sequence was repeated or moved backwards."
case .sessionChanged:
return "Stream session changed without reconnecting."
case .insecureEndpoint:
return "Only a bounded wss endpoint without embedded credentials is allowed."
case .invalidPairingToken:
return "Pairing token must be 32 to 512 visible ASCII characters."
}
}
}
public struct TrackEnvelopeDecoder: Sendable {
public static let schema = "ruview.nlos.track.v1"
public static let authenticatedSchema = "ruview.nlos.authenticated.v1"
public static let maximumFrameBytes = 256 * 1024
public static let maximumTracks = 16
public static let maximumLifetimeMs: UInt64 = 5_000
public static let maximumFutureSkewMs: UInt64 = 1_000
public static let maximumAuthenticationLifetimeMs: UInt64 = 60 * 60 * 1_000
public static let maximumInteroperableSequence: UInt64 = 9_007_199_254_740_991
public init() {}
public func decodeAuthenticated(
_ data: Data,
nowUnixMs: UInt64
) throws -> NLOSAuthenticatedSession {
guard data.count <= Self.maximumFrameBytes else {
throw NLOSValidationError.frameTooLarge(
actualBytes: data.count,
maximumBytes: Self.maximumFrameBytes
)
}
let message: NLOSAuthenticatedSession
do {
message = try JSONDecoder().decode(NLOSAuthenticatedSession.self, from: data)
} catch {
throw NLOSValidationError.malformedEnvelope
}
guard message.schema == Self.authenticatedSchema else {
throw NLOSValidationError.invalidField("authenticated.schema")
}
guard isSafeIdentifier(message.sessionId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("authenticated.sessionId")
}
guard message.expiresAtUnixMs > nowUnixMs else {
throw NLOSValidationError.staleFrame
}
guard message.expiresAtUnixMs <= Self.maximumInteroperableSequence else {
throw NLOSValidationError.invalidField("authenticated.expiresAtUnixMs")
}
guard message.expiresAtUnixMs - nowUnixMs
<= Self.maximumAuthenticationLifetimeMs + Self.maximumFutureSkewMs else {
throw NLOSValidationError.excessiveLifetime
}
return message
}
public func decode(_ data: Data, nowUnixMs: UInt64) throws -> ValidatedTrackEnvelope {
guard data.count <= Self.maximumFrameBytes else {
throw NLOSValidationError.frameTooLarge(
actualBytes: data.count,
maximumBytes: Self.maximumFrameBytes
)
}
let envelope: NLOSTrackEnvelope
do {
envelope = try JSONDecoder().decode(NLOSTrackEnvelope.self, from: data)
} catch {
throw NLOSValidationError.malformedEnvelope
}
try validate(envelope, nowUnixMs: nowUnixMs)
return ValidatedTrackEnvelope(value: envelope)
}
private func validate(_ envelope: NLOSTrackEnvelope, nowUnixMs: UInt64) throws {
guard envelope.schema == Self.schema else {
throw NLOSValidationError.invalidField("schema")
}
guard isSafeIdentifier(envelope.sessionId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("sessionId")
}
guard envelope.sequence <= Self.maximumInteroperableSequence else {
throw NLOSValidationError.invalidField("sequence")
}
guard envelope.capturedAtUnixMs <= Self.maximumInteroperableSequence,
envelope.expiresAtUnixMs <= Self.maximumInteroperableSequence else {
throw NLOSValidationError.invalidField("timestamp")
}
guard isSafeIdentifier(envelope.algorithmVersion, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("algorithmVersion")
}
guard isLowercaseSHA256(envelope.calibrationHash) else {
throw NLOSValidationError.invalidField("calibrationHash")
}
guard envelope.evidenceLevel != .l3Corroborated else {
throw NLOSValidationError.invalidField("evidenceLevel")
}
let zeroHash = String(repeating: "0", count: 64)
if envelope.source == .synthetic {
guard envelope.evidenceLevel == .l0Synthetic else {
throw NLOSValidationError.invalidField("evidenceLevel")
}
guard envelope.calibrationHash == zeroHash else {
throw NLOSValidationError.invalidField("calibrationHash")
}
guard envelope.provenance.transport == .replay else {
throw NLOSValidationError.invalidField("provenance.transport")
}
guard envelope.provenance.transientKind == .replay else {
throw NLOSValidationError.invalidField("provenance.transientKind")
}
} else if envelope.evidenceLevel >= .l2Calibrated,
envelope.calibrationHash == zeroHash {
throw NLOSValidationError.invalidField("calibrationHash")
}
guard envelope.expiresAtUnixMs > envelope.capturedAtUnixMs else {
throw NLOSValidationError.invalidField("expiresAtUnixMs")
}
guard envelope.expiresAtUnixMs - envelope.capturedAtUnixMs <= Self.maximumLifetimeMs else {
throw NLOSValidationError.excessiveLifetime
}
guard envelope.expiresAtUnixMs > nowUnixMs else {
throw NLOSValidationError.staleFrame
}
if envelope.capturedAtUnixMs > nowUnixMs {
guard envelope.capturedAtUnixMs - nowUnixMs <= Self.maximumFutureSkewMs else {
throw NLOSValidationError.futureDatedFrame
}
}
try validate(envelope.provenance, source: envelope.source, evidence: envelope.evidenceLevel)
guard envelope.tracks.count <= Self.maximumTracks else {
throw NLOSValidationError.invalidField("tracks")
}
var trackIds = Set<String>()
for track in envelope.tracks {
try validate(track)
guard trackIds.insert(track.trackId).inserted else {
throw NLOSValidationError.invalidField("tracks.trackId")
}
}
}
private func validate(
_ provenance: NLOSProvenance,
source: NLOSSource,
evidence: EvidenceLevel
) throws {
guard isSafeIdentifier(provenance.sensorId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("provenance.sensorId")
}
guard isSafeIdentifier(provenance.sensorModel, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("provenance.sensorModel")
}
guard isSafeIdentifier(provenance.firmwareVersion, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("provenance.firmwareVersion")
}
if source == .live {
guard evidence >= .l1Measured else {
throw NLOSValidationError.invalidField("evidenceLevel")
}
let isLiveHistogram = provenance.transientKind == .rawHistogram
|| provenance.transientKind == .compactNormalizedHistogram
guard provenance.histogramPreserved,
isLiveHistogram,
provenance.transport != .replay else {
throw NLOSValidationError.invalidField("provenance.transientKind")
}
} else if source == .replay {
guard provenance.transport == .replay,
provenance.transientKind == .replay,
provenance.histogramPreserved else {
throw NLOSValidationError.invalidField("provenance.transientKind")
}
}
}
private func validate(_ track: NLOSTrack) throws {
guard isSafeIdentifier(track.trackId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("tracks.trackId")
}
guard isFinite(track.positionM, absoluteMaximum: 100) else {
throw NLOSValidationError.invalidField("tracks.positionM")
}
guard isFinite(track.velocityMps, absoluteMaximum: 20) else {
throw NLOSValidationError.invalidField("tracks.velocityMps")
}
guard isFiniteNonnegative(track.covarianceDiagonalM2, maximum: 10) else {
throw NLOSValidationError.invalidField("tracks.covarianceDiagonalM2")
}
guard isUnitInterval(track.confidence) else {
throw NLOSValidationError.invalidField("tracks.confidence")
}
guard track.posteriorEntropy.isFinite, track.posteriorEntropy >= 0 else {
throw NLOSValidationError.invalidField("tracks.posteriorEntropy")
}
guard isUnitInterval(track.signalQuality) else {
throw NLOSValidationError.invalidField("tracks.signalQuality")
}
guard isUnitInterval(track.modalityContributions.lidar),
isUnitInterval(track.modalityContributions.csi) else {
throw NLOSValidationError.invalidField("tracks.modalityContributions")
}
let contributionSum = track.modalityContributions.lidar
+ track.modalityContributions.csi
guard contributionSum >= 0.999, contributionSum <= 1.001 else {
throw NLOSValidationError.invalidField("tracks.modalityContributions")
}
}
private func isUnitInterval(_ value: Double) -> Bool {
value.isFinite && value >= 0 && value <= 1
}
private func isFinite(_ vector: Vector3, absoluteMaximum: Double) -> Bool {
[vector.x, vector.y, vector.z].allSatisfy {
$0.isFinite && abs($0) <= absoluteMaximum
}
}
private func isFiniteNonnegative(_ vector: Vector3, maximum: Double) -> Bool {
[vector.x, vector.y, vector.z].allSatisfy {
$0.isFinite && $0 >= 0 && $0 <= maximum
}
}
private func isSafeIdentifier(_ value: String, maximumBytes: Int) -> Bool {
guard !value.isEmpty, value.utf8.count <= maximumBytes else { return false }
return value.unicodeScalars.allSatisfy { scalar in
let code = scalar.value
return (code >= 48 && code <= 57)
|| (code >= 65 && code <= 90)
|| (code >= 97 && code <= 122)
|| code == 45
|| code == 46
|| code == 58
|| code == 95
}
}
private func isLowercaseSHA256(_ value: String) -> Bool {
value.utf8.count == 64 && value.utf8.allSatisfy { byte in
(byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
}
}
}

View File

@@ -0,0 +1,87 @@
import Foundation
public struct TrackStreamGuard: Sendable {
private var boundSessionId: String?
private var lastSequence: UInt64?
public init() {}
public mutating func resetForReconnect() {
boundSessionId = nil
lastSequence = nil
}
public mutating func accept(
_ envelope: ValidatedTrackEnvelope,
nowUnixMs: UInt64
) throws -> TrackDisplayFrame {
guard envelope.isFresh(atUnixMs: nowUnixMs) else {
throw NLOSValidationError.staleFrame
}
let value = envelope.value
if let boundSessionId {
guard value.sessionId == boundSessionId else {
throw NLOSValidationError.sessionChanged
}
}
if let lastSequence {
guard value.sequence > lastSequence else {
throw NLOSValidationError.replayedSequence
}
}
boundSessionId = value.sessionId
lastSequence = value.sequence
return TrackDisplayFrame(
sessionId: value.sessionId,
sequence: value.sequence,
capturedAtUnixMs: value.capturedAtUnixMs,
expiresAtUnixMs: value.expiresAtUnixMs,
source: value.source,
evidenceLevel: value.evidenceLevel,
algorithmVersion: value.algorithmVersion,
provenance: value.provenance,
tracks: envelope.visibleTracks,
watermark: envelope.watermark
)
}
}
public enum WSSConnectionValidator {
public static func validate(endpoint: URL, pairingToken: String) throws {
_ = try credentialAccount(for: endpoint)
try validatePairingToken(pairingToken)
}
/// Return a normalized Keychain account only for the exact NLOS socket
/// endpoint. Credentials are thereby bound to one authority.
public static func credentialAccount(for endpoint: URL) throws -> String {
let components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)
guard endpoint.absoluteString.utf8.count <= 2_048,
endpoint.scheme?.lowercased() == "wss",
let host = endpoint.host,
!host.isEmpty,
endpoint.user == nil,
endpoint.password == nil,
endpoint.fragment == nil,
endpoint.path == "/api/v1/nlos/ws",
components?.percentEncodedQuery == nil else {
throw NLOSValidationError.insecureEndpoint
}
if let port = endpoint.port, !(1...65_535).contains(port) {
throw NLOSValidationError.insecureEndpoint
}
return "\(host.lowercased()):\(endpoint.port ?? 443)"
}
public static func validatePairingToken(_ pairingToken: String) throws {
guard (32...512).contains(pairingToken.utf8.count),
pairingToken.utf8.allSatisfy({ $0 >= 0x21 && $0 <= 0x7e }) else {
throw NLOSValidationError.invalidPairingToken
}
}
}

View File

@@ -0,0 +1,31 @@
import XCTest
@testable import RuViewNLOSApple
final class AppleCapabilityProbeTests: XCTestCase {
func testRawPhotonHistogramsAreNeverClaimedByPublicAppleProbe() {
let report = AppleCapabilityProbe.probe()
XCTAssertEqual(report.rawPhotonHistograms, .unavailable)
XCTAssertFalse(report.rawPhotonHistogramReason.isEmpty)
}
#if !canImport(ARKit)
func testNonAppleBuildHostReportsARKitSignalsUnavailable() {
let report = AppleCapabilityProbe.probe()
XCTAssertEqual(report.sceneDepth, .unavailable)
XCTAssertEqual(report.smoothedSceneDepth, .unavailable)
XCTAssertEqual(report.sceneMesh, .unavailable)
XCTAssertEqual(report.worldPose, .unavailable)
}
#endif
#if !canImport(Security)
func testNonAppleBuildHostDoesNotFallBackToPlaintextTokenStorage() {
let store = KeychainPairingTokenStore()
XCTAssertThrowsError(try store.save(String(repeating: "A", count: 32)))
XCTAssertThrowsError(try store.load())
}
#endif
}

View File

@@ -0,0 +1,525 @@
import Foundation
import XCTest
@testable import RuViewNLOSCore
final class TrackEnvelopeDecoderTests: XCTestCase {
private let nowUnixMs: UInt64 = 1_800_000_000_000
private let decoder = TrackEnvelopeDecoder()
func testValidLiveEnvelopeDecodes() throws {
let decoded = try decoder.decode(makeEnvelope(), nowUnixMs: nowUnixMs)
XCTAssertEqual(decoded.value.schema, TrackEnvelopeDecoder.schema)
XCTAssertEqual(decoded.value.source, .live)
XCTAssertEqual(decoded.value.evidenceLevel, .l2Calibrated)
XCTAssertEqual(decoded.visibleTracks.map(\.trackId), ["target-1"])
XCTAssertNil(decoded.watermark)
}
func testAuthenticatedAcknowledgementUsesExactBoundedContract() throws {
let object: [String: Any] = [
"schema": TrackEnvelopeDecoder.authenticatedSchema,
"sessionId": "session-a",
"expiresAtUnixMs": NSNumber(value: nowUnixMs + 60_000),
]
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
let authenticated = try decoder.decodeAuthenticated(data, nowUnixMs: nowUnixMs)
XCTAssertEqual(authenticated.sessionId, "session-a")
var unexpected = object
unexpected["extra"] = true
let malformed = try JSONSerialization.data(
withJSONObject: unexpected,
options: [.sortedKeys]
)
XCTAssertThrowsError(
try decoder.decodeAuthenticated(malformed, nowUnixMs: nowUnixMs)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .malformedEnvelope)
}
var excessive = object
excessive["expiresAtUnixMs"] = NSNumber(
value: nowUnixMs + TrackEnvelopeDecoder.maximumAuthenticationLifetimeMs + 1_001
)
let excessiveData = try JSONSerialization.data(
withJSONObject: excessive,
options: [.sortedKeys]
)
XCTAssertThrowsError(
try decoder.decodeAuthenticated(excessiveData, nowUnixMs: nowUnixMs)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .excessiveLifetime)
}
}
func testUnknownTrackIsAcceptedButNeverDisplayable() throws {
let decoded = try decoder.decode(
makeEnvelope(trackState: "unknown"),
nowUnixMs: nowUnixMs
)
XCTAssertEqual(decoded.value.tracks.count, 1)
XCTAssertTrue(decoded.visibleTracks.isEmpty)
}
func testStaleFrameFailsClosed() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(capturedAtUnixMs: nowUnixMs - 2_000, expiresAtUnixMs: nowUnixMs),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .staleFrame)
}
}
func testFutureFrameOutsideSkewFailsClosed() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
capturedAtUnixMs: nowUnixMs + 1_001,
expiresAtUnixMs: nowUnixMs + 2_000
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .futureDatedFrame)
}
}
func testLifetimeOverFiveSecondsIsRejected() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
capturedAtUnixMs: nowUnixMs - 100,
expiresAtUnixMs: nowUnixMs + 5_000
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .excessiveLifetime)
}
}
func testLiveDepthOnlyInputCannotBecomeNLOSEvidence() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(transientKind: "depth_only", histogramPreserved: false),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(
error as? NLOSValidationError,
.invalidField("provenance.transientKind")
)
}
}
func testLiveFrameRequiresPreservedHistogram() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(histogramPreserved: false),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(
error as? NLOSValidationError,
.invalidField("provenance.transientKind")
)
}
}
func testReplayTransientCannotBeRelabeledLive() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
transientKind: "replay",
histogramPreserved: true,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(
error as? NLOSValidationError,
.invalidField("provenance.transientKind")
)
}
}
func testReplayTransportCannotBeRelabeledAsLiveHistogram() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
transientKind: "compact_normalized_histogram",
histogramPreserved: true,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(
error as? NLOSValidationError,
.invalidField("provenance.transientKind")
)
}
}
func testSyntheticFrameRequiresL0AndCarriesWatermark() throws {
let zeroHash = String(repeating: "0", count: 64)
let decoded = try decoder.decode(
makeEnvelope(
source: "synthetic",
evidenceLevel: "l0_synthetic",
calibrationHash: zeroHash,
transientKind: "replay",
histogramPreserved: false,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
XCTAssertEqual(decoded.watermark, "SYNTHETIC")
XCTAssertNoThrow(
try decoder.decode(
makeEnvelope(
source: "synthetic",
evidenceLevel: "l0_synthetic",
calibrationHash: zeroHash,
transientKind: "replay",
histogramPreserved: true,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
)
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
source: "synthetic",
evidenceLevel: "l1_measured",
calibrationHash: zeroHash,
transientKind: "replay",
histogramPreserved: false,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("evidenceLevel"))
}
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
source: "synthetic",
evidenceLevel: "l0_synthetic",
transientKind: "replay",
histogramPreserved: false,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("calibrationHash"))
}
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(
source: "synthetic",
evidenceLevel: "l0_synthetic",
calibrationHash: zeroHash,
transientKind: "replay",
histogramPreserved: false,
transport: "ruview_server"
),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("provenance.transport"))
}
}
func testCalibratedNonSyntheticFrameCannotUseZeroCalibrationHash() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(calibrationHash: String(repeating: "0", count: 64)),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("calibrationHash"))
}
}
func testV1RejectsL3WithoutDualModalityLineage() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(evidenceLevel: "l3_corroborated"),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("evidenceLevel"))
}
}
func testMeasuredUncalibratedReplayCanUseZeroCalibrationHash() throws {
let decoded = try decoder.decode(
makeEnvelope(
source: "replay",
evidenceLevel: "l1_measured",
calibrationHash: String(repeating: "0", count: 64),
transientKind: "replay",
histogramPreserved: true,
transport: "replay"
),
nowUnixMs: nowUnixMs
)
XCTAssertEqual(decoded.value.evidenceLevel, .l1Measured)
XCTAssertEqual(decoded.watermark, "REPLAY")
}
func testOversizedFrameIsRejectedBeforeJSONParsing() {
let oversized = Data(
repeating: 0x20,
count: TrackEnvelopeDecoder.maximumFrameBytes + 1
)
XCTAssertThrowsError(try decoder.decode(oversized, nowUnixMs: nowUnixMs)) { error in
XCTAssertEqual(
error as? NLOSValidationError,
.frameTooLarge(
actualBytes: TrackEnvelopeDecoder.maximumFrameBytes + 1,
maximumBytes: TrackEnvelopeDecoder.maximumFrameBytes
)
)
}
}
func testPositionAndTrackCountBoundsAreEnforced() {
XCTAssertThrowsError(
try decoder.decode(makeEnvelope(positionX: 100.001), nowUnixMs: nowUnixMs)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("tracks.positionM"))
}
let tracks = (0...TrackEnvelopeDecoder.maximumTracks).map { index in
makeTrack(trackId: "target-\(index)")
}
XCTAssertThrowsError(
try decoder.decode(makeEnvelope(tracks: tracks), nowUnixMs: nowUnixMs)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("tracks"))
}
}
func testModalityContributionsMustSumToOne() {
var track = makeTrack(trackId: "target-1")
track["modalityContributions"] = ["lidar": 0.8, "csi": 0.8]
XCTAssertThrowsError(
try decoder.decode(makeEnvelope(tracks: [track]), nowUnixMs: nowUnixMs)
) { error in
XCTAssertEqual(
error as? NLOSValidationError,
.invalidField("tracks.modalityContributions")
)
}
}
func testSequenceMustRemainInteroperableWithWebClients() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(sequence: TrackEnvelopeDecoder.maximumInteroperableSequence + 1),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("sequence"))
}
}
func testUnknownObjectKeysAreRejected() {
XCTAssertThrowsError(
try decoder.decode(
makeEnvelope(includeUnknownRootKey: true),
nowUnixMs: nowUnixMs
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .malformedEnvelope)
}
}
func testDuplicateTrackIdsAreRejected() {
let track = makeTrack(trackId: "duplicate")
XCTAssertThrowsError(
try decoder.decode(makeEnvelope(tracks: [track, track]), nowUnixMs: nowUnixMs)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidField("tracks.trackId"))
}
}
func testStreamGuardRejectsReplayAndSessionSwitch() throws {
var guardrail = TrackStreamGuard()
let first = try decoder.decode(
makeEnvelope(sessionId: "session-a", sequence: 7),
nowUnixMs: nowUnixMs
)
let repeated = try decoder.decode(
makeEnvelope(sessionId: "session-a", sequence: 7),
nowUnixMs: nowUnixMs
)
let changedSession = try decoder.decode(
makeEnvelope(sessionId: "session-b", sequence: 8),
nowUnixMs: nowUnixMs
)
XCTAssertEqual(try guardrail.accept(first, nowUnixMs: nowUnixMs).sequence, 7)
XCTAssertThrowsError(try guardrail.accept(repeated, nowUnixMs: nowUnixMs)) { error in
XCTAssertEqual(error as? NLOSValidationError, .replayedSequence)
}
XCTAssertThrowsError(try guardrail.accept(changedSession, nowUnixMs: nowUnixMs)) { error in
XCTAssertEqual(error as? NLOSValidationError, .sessionChanged)
}
}
func testReconnectAllowsNewSessionButRetainsMonotonicRule() throws {
var guardrail = TrackStreamGuard()
let first = try decoder.decode(
makeEnvelope(sessionId: "session-a", sequence: 99),
nowUnixMs: nowUnixMs
)
let afterReconnect = try decoder.decode(
makeEnvelope(sessionId: "session-b", sequence: 0),
nowUnixMs: nowUnixMs
)
_ = try guardrail.accept(first, nowUnixMs: nowUnixMs)
guardrail.resetForReconnect()
XCTAssertEqual(try guardrail.accept(afterReconnect, nowUnixMs: nowUnixMs).sessionId, "session-b")
}
func testWSSAndPairingTokenValidation() throws {
let validToken = String(repeating: "A", count: 32)
let secureURL = try XCTUnwrap(URL(string: "wss://127.0.0.1:9443/api/v1/nlos/ws"))
XCTAssertNoThrow(
try WSSConnectionValidator.validate(endpoint: secureURL, pairingToken: validToken)
)
XCTAssertEqual(
try WSSConnectionValidator.credentialAccount(for: secureURL),
"127.0.0.1:9443"
)
let insecureURL = try XCTUnwrap(URL(string: "ws://127.0.0.1:9443/nlos"))
XCTAssertThrowsError(
try WSSConnectionValidator.validate(endpoint: insecureURL, pairingToken: validToken)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .insecureEndpoint)
}
let embeddedCredentialURL = try XCTUnwrap(URL(string: "wss://user@example.test/nlos"))
XCTAssertThrowsError(
try WSSConnectionValidator.validate(
endpoint: embeddedCredentialURL,
pairingToken: validToken
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .insecureEndpoint)
}
for unsafe in [
"wss://example.test/nlos/v1/tracks",
"wss://example.test/api/v1/nlos/ws?token=secret",
"wss://example.test/api/v1/nlos/ws#fragment",
] {
let endpoint = try XCTUnwrap(URL(string: unsafe))
XCTAssertThrowsError(
try WSSConnectionValidator.validate(endpoint: endpoint, pairingToken: validToken)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .insecureEndpoint)
}
}
XCTAssertThrowsError(
try WSSConnectionValidator.validate(
endpoint: secureURL,
pairingToken: "valid-looking-token\r\nInjected: yes"
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidPairingToken)
}
XCTAssertThrowsError(
try WSSConnectionValidator.validate(
endpoint: secureURL,
pairingToken: String(repeating: "A", count: 31)
)
) { error in
XCTAssertEqual(error as? NLOSValidationError, .invalidPairingToken)
}
}
private func makeEnvelope(
sessionId: String = "session-a",
sequence: UInt64 = 7,
source: String = "live",
evidenceLevel: String = "l2_calibrated",
calibrationHash: String = String(repeating: "a", count: 64),
transientKind: String = "raw_histogram",
histogramPreserved: Bool = true,
transport: String = "ruview_server",
trackState: String = "tracking",
positionX: Double = 1.25,
capturedAtUnixMs: UInt64? = nil,
expiresAtUnixMs: UInt64? = nil,
tracks: [[String: Any]]? = nil,
includeUnknownRootKey: Bool = false
) -> Data {
let captured = capturedAtUnixMs ?? nowUnixMs - 100
let expires = expiresAtUnixMs ?? nowUnixMs + 1_000
let resolvedTracks = tracks ?? [
makeTrack(trackId: "target-1", state: trackState, positionX: positionX),
]
var object: [String: Any] = [
"schema": TrackEnvelopeDecoder.schema,
"sessionId": sessionId,
"sequence": NSNumber(value: sequence),
"capturedAtUnixMs": NSNumber(value: captured),
"expiresAtUnixMs": NSNumber(value: expires),
"source": source,
"evidenceLevel": evidenceLevel,
"algorithmVersion": "consumer-nlos-0.1.0",
"calibrationHash": calibrationHash,
"provenance": [
"sensorId": "spad-01",
"sensorModel": "VL53L8CH",
"firmwareVersion": "1.0.0",
"transientKind": transientKind,
"histogramPreserved": histogramPreserved,
"transport": transport,
],
"tracks": resolvedTracks,
]
if includeUnknownRootKey {
object["unexpected"] = true
}
return try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
}
private func makeTrack(
trackId: String,
state: String = "tracking",
positionX: Double = 1.25
) -> [String: Any] {
[
"trackId": trackId,
"state": state,
"positionM": ["x": positionX, "y": 0.4, "z": -2.5],
"velocityMps": ["x": 0.1, "y": 0, "z": -0.2],
"covarianceDiagonalM2": ["x": 0.04, "y": 0.09, "z": 0.04],
"confidence": 0.88,
"posteriorEntropy": 0.32,
"signalQuality": 0.74,
"modalityContributions": ["lidar": 0.7, "csi": 0.3],
]
}
}

View File

@@ -7,6 +7,7 @@ WiFi-DensePose Mobile is a React Native / Expo companion app for the [WiFi-Dense
> | Screen | What It Shows |
> |--------|---------------|
> | **Live** | 3D Gaussian splat body rendering with FPS counter, signal strength, confidence HUD |
> | **NLOS** | Authenticated hidden-target hypotheses with 2D plan / 3D perspective views, evidence provenance, freshness, and bounded synthetic replay |
> | **Vitals** | Breathing rate (6-30 BPM) and heart rate (40-120 BPM) arc gauges with sparkline history |
> | **Zones** | SVG floor plan with occupancy grid, zone legend, presence heatmap |
> | **MAT** | Mass casualty assessment: survivor counter, triage alerts, zone management |
@@ -29,6 +30,7 @@ npx expo start --web
| | Feature | Details |
|---|---------|---------|
| **3D Live View** | Gaussian splat rendering | Three.js via WebView (native) or iframe (web), real-time pose overlay |
| **RuView NLOS Labs** | Hidden-target tracks | Authenticated `ruview.nlos.track.v1` frames, strict bounds, replay rejection, staleness, and visible provenance |
| **Vital Signs** | Breathing + heart rate | Arc gauge components with sparkline 60-sample history, confidence indicators |
| **Disaster Response** | WiFi-MAT dashboard | Survivor detection, START triage classification, priority alerts, zone scan tracking |
| **Floor Plan** | SVG occupancy grid | Zone-level presence visualization, color-coded density, interactive legend |
@@ -39,6 +41,15 @@ npx expo start --web
| **Persistent State** | Zustand + AsyncStorage | Settings, connection preferences, and theme survive app restarts |
| **Platform WiFi** | Native RSSI scanning | Android: `react-native-wifi-reborn`, iOS: stub (requires entitlement), Web: synthetic values |
### RuView NLOS on iOS and the web
The NLOS tab is a cross-platform **track client**, not an iPhone LiDAR capture implementation. Apple Safari, Expo, and ordinary App Store APIs do not expose the raw photon timing histograms required by the research reconstruction pipeline. The client therefore accepts only:
1. Live track frames from an authenticated RuView NLOS server session, after a transport-layer Bearer token is exchanged for a single-use WebSocket ticket.
2. Deterministic `SYNTHETIC` replay, always labeled `l0_synthetic` and always covered by a visible watermark.
Unknown, expired, out-of-order, malformed, oversized, depth-only, or unauthenticated data is never presented as live NLOS. A native host can provide an ephemeral credential with `configureNlosBearerToken`, or an operator can paste a 32-to-512-character pairing credential into the masked NLOS screen input. The credential remains in memory, is sent only in the ticket request `Authorization` header, and is never persisted by this client.
---
## Prerequisites
@@ -133,6 +144,7 @@ ui/mobile/
websocket.ts WS path, reconnect delays, max attempts
hooks/
usePoseStream.ts Subscribe to live or simulated sensing frames
useNlosStream.ts Authenticated NLOS / deterministic replay lifecycle
useRssiScanner.ts Platform RSSI scanning hook
useServerReachability.ts HTTP health check polling
useTheme.ts Dark/light/system theme resolution
@@ -148,6 +160,10 @@ ui/mobile/
GaussianSplatWebView.web.tsx Web iframe renderer
LiveHUD.tsx FPS, RSSI, confidence, person count overlay
useGaussianBridge.ts WebView message protocol
NLOSScreen/
index.tsx NLOS evidence, controls, metrics, and safe fallback UI
HiddenTargetVisualization.tsx Memoized plan / perspective SVG renderer
ProvenancePanel.tsx Source, evidence, histogram, and freshness disclosure
VitalsScreen/
index.tsx Breathing + heart rate dashboard
BreathingGauge.tsx Arc gauge for breathing BPM
@@ -172,6 +188,8 @@ ui/mobile/
ThemePicker.tsx Dark / light / system theme selector
services/
ws.service.ts WebSocket client with auto-reconnect + simulation fallback
nlos.service.ts Bearer ticket exchange, bounded WebSocket, replay rejection
nlos.validation.ts Strict versioned NLOS track frame validation
api.service.ts REST client (Axios) with retry logic
rssi.service.ts Platform-agnostic RSSI scanner interface
rssi.service.android.ts Android: react-native-wifi-reborn integration
@@ -180,6 +198,7 @@ ui/mobile/
simulation.service.ts Generates synthetic SensingFrame data
stores/
poseStore.ts Pose frames, connection status, frame history (Zustand)
nlosStore.ts NLOS frame ordering, provenance, rejection, and staleness
matStore.ts MAT survivors, zones, alerts, disaster events (Zustand)
settingsStore.ts Server URL, theme, RSSI toggle (Zustand + persist)
theme/
@@ -190,6 +209,7 @@ ui/mobile/
index.ts Theme barrel export
types/
sensing.ts SensingFrame, SensingNode, VitalsData, Classification
nlos.ts Canonical `ruview.nlos.track.v1` wire contract
mat.ts Survivor, Alert, ScanZone, TriageStatus, DisasterType
api.ts PoseStatus, ZoneConfig, HistoricalFrames, ApiError
navigation.ts Navigation param lists
@@ -249,6 +269,12 @@ The primary visualization screen. Renders a 3D Gaussian splat representation of
Displays real-time breathing rate and heart rate extracted from CSI signal processing. Each vital sign is shown as an animated arc gauge (`GaugeArc` component) with the current BPM value, a 60-sample sparkline history (`SparklineChart`), and a confidence percentage. Normal ranges: breathing 6-30 BPM, heart rate 40-120 BPM.
### NLOS
Displays hidden-target hypotheses produced upstream by RuView NLOS. The 2D plan and lightweight 3D perspective views render at most 16 tracks and covariance ellipses. The provenance card reports evidence level, transient kind, histogram preservation, sensor model, sequence, and freshness. Stale tracks remain visible only as muted historical context beneath a `STALE FRAME` overlay.
The NLOS server URL is configured separately from the CSI socket. Live authentication uses `POST /api/v1/nlos/ws-ticket` with a Bearer credential; the response supplies a short-lived single-use `wss` URL. If no ephemeral credential is available, the tab starts in deterministic synthetic replay rather than silently relabeling simulated data as live.
### Zones
A floor plan view that maps WiFi sensing coverage to physical space. Uses SVG rendering (`react-native-svg`) to draw zones with color-coded occupancy density. The `useOccupancyGrid` hook computes grid cell values from incoming sensing frames. A legend shows the color scale from empty to high-density zones.
@@ -259,8 +285,9 @@ Mass Casualty Assessment Tool for disaster response. Displays a survivor counter
### Settings
Configuration panel with four controls:
Configuration panel with separate sensing and NLOS controls:
- **Server URL** — text input with URL validation; changes trigger WebSocket reconnect
- **RuView NLOS server URL** — separate base URL used only for the authenticated ticket exchange
- **Theme** — dark / light / system picker
- **RSSI Scanning** — toggle for platform-native WiFi RSSI scanning
- **Alert Sound** — toggle for MAT alert audio notifications
@@ -314,6 +341,22 @@ The REST client (`api.service.ts`) provides:
All requests use Axios with a 5-second timeout and automatic retry (2 attempts).
### RuView NLOS protocol
The NLOS client exchanges its in-memory Bearer credential at `POST /api/v1/nlos/ws-ticket`. The ticket response is capped at 8 KiB and must be exactly:
```json
{
"schema": "ruview.nlos.ws-ticket.v1",
"webSocketUrl": "wss://ruview.example/api/v1/nlos/ws?ticket=single-use",
"expiresAtUnixMs": 1770000000000
}
```
The first WebSocket message must be `ruview.nlos.authenticated.v1`. Only then can the socket deliver `ruview.nlos.track.v1` frames for the same session. Track JSON is capped at 256 KiB and 16 tracks. Positions are bounded to ±100 m, velocity to ±20 m/s, covariance to 10 m², and expiration to five seconds. Sequence values must increase monotonically.
Live frames require at least `l1_measured` evidence, preserved raw or compact normalized histograms, and `ruview_server` transport provenance. `depth_only` data cannot be labeled live NLOS. Synthetic frames require `l0_synthetic`, replay transport, the zero calibration hash, and the on-screen `SYNTHETIC` watermark.
</details>
---
@@ -332,10 +375,10 @@ Runs the Jest test suite via `jest-expo`. Tests cover:
| Category | Files | What Is Tested |
|----------|-------|----------------|
| Components | 7 | `ConnectionBanner`, `GaugeArc`, `HudOverlay`, `OccupancyGrid`, `SignalBar`, `SparklineChart`, `StatusDot` |
| Screens | 5 | `LiveScreen`, `VitalsScreen`, `ZonesScreen`, `MATScreen`, `SettingsScreen` |
| Services | 4 | `ws.service`, `api.service`, `rssi.service`, `simulation.service` |
| Stores | 3 | `poseStore`, `matStore`, `settingsStore` |
| Hooks | 3 | `usePoseStream`, `useRssiScanner`, `useServerReachability` |
| Screens | 6 | Existing screens plus `NLOSScreen` |
| Services | 6 | Existing services plus NLOS transport and protocol validation |
| Stores | 4 | Existing stores plus NLOS ordering, provenance, and staleness state |
| Hooks | 4 | Existing hooks plus the NLOS authenticated/replay lifecycle |
| Utils | 3 | `colorMap`, `ringBuffer`, `urlValidator` |
### End-to-End Tests (Maestro)

View File

@@ -0,0 +1,59 @@
const eslint = require('@eslint/js');
const typescriptParser = require('@typescript-eslint/parser');
const typescriptPlugin = require('@typescript-eslint/eslint-plugin');
const reactHooks = require('eslint-plugin-react-hooks');
const globals = require('globals');
const sourceFiles = ['**/*.{js,jsx,ts,tsx}'];
const typescriptFiles = ['**/*.{ts,tsx}'];
module.exports = [
{
ignores: ['node_modules/**', 'dist/**', '.expo/**', 'coverage/**', 'src/assets/webview/**'],
},
{
files: sourceFiles,
...eslint.configs.recommended,
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2025,
...globals.jest,
...globals.node,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
'react-hooks': reactHooks,
},
rules: {
...eslint.configs.recommended.rules,
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
{
files: typescriptFiles,
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: { jsx: true },
project: './tsconfig.json',
},
},
plugins: {
'@typescript-eslint': typescriptPlugin,
},
rules: {
...typescriptPlugin.configs.recommended.rules,
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
];

View File

@@ -7,7 +7,7 @@ module.exports = {
...(expoPreset.setupFiles || []),
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'],
testPathIgnorePatterns: ['/node_modules/', '/__mocks__/', '<rootDir>/src/__tests__/test-utils.tsx'],
transformIgnorePatterns: [
'node_modules/(?!(expo|expo-.+|react-native|@react-native|react-native-webview|react-native-reanimated|react-native-svg|react-native-safe-area-context|react-native-screens|@react-navigation|@expo|@unimodules|expo-modules-core|react-native-worklets)/)',
],

View File

@@ -7,7 +7,7 @@ jest.mock('react-native-wifi-reborn', () => ({
}));
jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock')
require('./src/__tests__/__mocks__/reanimated')
);
jest.mock('react-native-webview', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
"ios": "expo start --ios",
"web": "expo start --web",
"test": "jest",
"typecheck": "tsc --noEmit",
"lint": "eslint ."
},
"dependencies": {
@@ -18,10 +19,10 @@
"@types/three": "^0.183.1",
"axios": "^1.15.2",
"expo": "~55.0.4",
"expo-status-bar": "~55.0.4",
"expo-status-bar": "~55.0.6",
"react": "19.2.0",
"react-dom": "19.2.6",
"react-native": "0.85.2",
"react-dom": "19.2.0",
"react-native": "0.83.10",
"react-native-gesture-handler": "~2.30.0",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.2",
@@ -35,18 +36,21 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/jest": "29.5.14",
"@types/react": "~19.2.2",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.56.1",
"babel-preset-expo": "^55.0.10",
"eslint": "^10.2.1",
"jest": "^30.2.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.11.0",
"jest": "~29.7.0",
"jest-expo": "^55.0.9",
"prettier": "^3.8.3",
"react-native-worklets": "^0.7.4",
"react-native-worklets": "0.7.4",
"typescript": "~5.9.2"
},
"overrides": {

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