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