feat: Add Pilot tab — MJPEG camera feed + virtual joystick RC control

- New PilotView.swift: full-screen MJPEG stream via WKWebView, virtual
  joystick overlay (bottom-right, semi-transparent), camera switcher
  pill (top-right, hidden when single source)
- Dead-man switch: finger lift snaps joystick to zero; next 10 Hz tick
  publishes {linear_x:0, angular_z:0} within 100 ms
- cmd_vel published to saltybot/cmd_vel at 10 Hz max via MQTT
- SensorManager: add ensureMQTTConnected(), releaseMQTTIfIdle(),
  publishCmdVel(linearX:angularZ:) so Pilot tab can use MQTT
  independently of Follow-Me streaming
- ContentView: add Pilot tab (camera.fill icon, last tab)
- xcodeproj: register PilotView.swift in Sources build phase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sl-ios 2026-04-06 20:47:30 -04:00
parent 615dc405d0
commit f954b844d4
4 changed files with 213 additions and 0 deletions

View File

@ -23,6 +23,7 @@
A10000010000000000000DAA /* BLEPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000DAB /* BLEPackets.swift */; };
A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; };
A10000010000000000000FAA /* BLEStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000FAB /* BLEStatusView.swift */; };
A100000100000000000014AA /* PilotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000014AB /* PilotView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -44,6 +45,7 @@
A10000010000000000000DAB /* BLEPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEPackets.swift; sourceTree = "<group>"; };
A10000010000000000000EAB /* AnchorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorInfo.swift; sourceTree = "<group>"; };
A10000010000000000000FAB /* BLEStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEStatusView.swift; sourceTree = "<group>"; };
A100000100000000000014AB /* PilotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PilotView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -83,6 +85,7 @@
A100000100000000000011BB /* RouteStore.swift */,
A100000100000000000012BB /* RouteRecorder.swift */,
A100000100000000000013BB /* RoutesView.swift */,
A100000100000000000014AB /* PilotView.swift */,
A100000100000000000005AB /* Assets.xcassets */,
A100000100000000000006AB /* Info.plist */,
);
@ -182,6 +185,7 @@
A100000100000000000011AB /* RouteStore.swift in Sources */,
A100000100000000000012AB /* RouteRecorder.swift in Sources */,
A100000100000000000013AB /* RoutesView.swift in Sources */,
A100000100000000000014AA /* PilotView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -15,6 +15,8 @@ struct ContentView: View {
.tabItem { Label("BLE Tag", systemImage: "dot.radiowaves.right") }
RoutesView()
.tabItem { Label("Routes", systemImage: "point.bottomleft.forward.to.point.topright.scurvepath") }
PilotView()
.tabItem { Label("Pilot", systemImage: "camera.fill") }
}
}
}

View File

@ -0,0 +1,187 @@
import SwiftUI
import WebKit
// MARK: - Camera source
struct CameraSource: Identifiable, Hashable {
let id: String
let name: String
let url: String
}
// MARK: - MJPEG stream renderer (WKWebView)
/// Renders an MJPEG stream URL inside a black-background WKWebView.
/// Reloads only when `urlString` changes (guarded by Coordinator).
struct MJPEGStreamView: UIViewRepresentable {
let urlString: String
class Coordinator {
var loadedURL = ""
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.allowsInlineMediaPlayback = true
let wv = WKWebView(frame: .zero, configuration: cfg)
wv.backgroundColor = .black
wv.isOpaque = true
wv.scrollView.isScrollEnabled = false
wv.scrollView.bounces = false
return wv
}
func updateUIView(_ wv: WKWebView, context: Context) {
guard context.coordinator.loadedURL != urlString else { return }
context.coordinator.loadedURL = urlString
let html = """
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; width: 100vw; height: 100vh;
display: flex; align-items: center; justify-content: center; overflow: hidden; }
img { max-width: 100%; max-height: 100%; object-fit: contain; }
</style>
</head><body>
<img src="\(urlString)">
</body></html>
"""
wv.loadHTMLString(html, baseURL: nil)
}
}
// MARK: - Virtual joystick
/// Single-touch virtual joystick. Knob snaps back to center on release (dead-man).
/// `value.x` [-1, 1] (right positive), `value.y` [-1, 1] (down positive).
struct JoystickControl: View {
@Binding var value: CGPoint
private let baseRadius: CGFloat = 64
private let knobRadius: CGFloat = 24
@State private var offset: CGSize = .zero
var body: some View {
let travel = baseRadius - knobRadius
let cx = min(max(offset.width, -travel), travel)
let cy = min(max(offset.height, -travel), travel)
ZStack {
// Base ring
Circle()
.fill(.white.opacity(0.12))
.frame(width: baseRadius * 2, height: baseRadius * 2)
.overlay(Circle().stroke(.white.opacity(0.35), lineWidth: 1.5))
// Knob
Circle()
.fill(.white.opacity(offset == .zero ? 0.35 : 0.70))
.frame(width: knobRadius * 2, height: knobRadius * 2)
.offset(x: cx, y: cy)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { g in
offset = g.translation
let travel = baseRadius - knobRadius
value = CGPoint(
x: min(max(g.translation.width / travel, -1), 1),
y: min(max(g.translation.height / travel, -1), 1)
)
}
.onEnded { _ in
// Dead-man: finger lifted snap to zero immediately.
// The cmd timer will publish zeros on the next tick ( 100 ms).
offset = .zero
value = .zero
}
)
}
}
// MARK: - Pilot view
struct PilotView: View {
@EnvironmentObject var sensor: SensorManager
private static let cameras: [CameraSource] = [
CameraSource(id: "realsense", name: "RealSense", url: "http://100.64.0.2:8888/stream"),
// Add more cameras here: CameraSource(id: "...", name: "...", url: "...")
]
@State private var selectedCamIndex = 0
@State private var joystick: CGPoint = .zero
/// 10 Hz publish timer rate-limits cmd_vel to Orin.
private let cmdTimer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack(alignment: .bottomTrailing) {
Color.black.ignoresSafeArea()
// Full-screen camera feed
MJPEGStreamView(urlString: Self.cameras[selectedCamIndex].url)
.ignoresSafeArea()
// Camera switcher pill top-right (hidden when only one source)
if Self.cameras.count > 1 {
VStack {
HStack {
Spacer()
cameraSwitcher
.padding(.top, 56)
.padding(.trailing, 16)
}
Spacer()
}
}
// Joystick overlay bottom-right, semi-transparent
JoystickControl(value: $joystick)
.padding(.trailing, 32)
.padding(.bottom, 52)
}
// Publish at exactly 10 Hz joystick value is zero when not touched.
.onReceive(cmdTimer) { _ in
sensor.publishCmdVel(
linearX: Float(-joystick.y), // drag up positive linear_x (forward)
angularZ: Float(-joystick.x) // drag left positive angular_z (counter-cw)
)
}
.onAppear { sensor.ensureMQTTConnected() }
.onDisappear {
// Safety: publish a stop command and release MQTT if Follow-Me isn't using it.
joystick = .zero
sensor.publishCmdVel(linearX: 0, angularZ: 0)
sensor.releaseMQTTIfIdle()
}
}
private var cameraSwitcher: some View {
Menu {
ForEach(Array(Self.cameras.enumerated()), id: \.element.id) { i, cam in
Button {
selectedCamIndex = i
} label: {
if selectedCamIndex == i {
Label(cam.name, systemImage: "checkmark")
} else {
Text(cam.name)
}
}
}
} label: {
Label(Self.cameras[selectedCamIndex].name,
systemImage: "camera.on.rectangle.fill")
.font(.caption.bold())
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(.ultraThinMaterial, in: Capsule())
}
.foregroundStyle(.white)
}
}

View File

@ -242,6 +242,26 @@ final class SensorManager: NSObject, ObservableObject {
followPreset = preset; publishFollowPreset()
}
// MARK: - MQTT: Pilot tab lifecycle + cmd_vel
/// Ensure MQTT is open. Safe to call even when Follow-Me is already streaming.
func ensureMQTTConnected() {
mqtt.connect() // no-op if state != .disconnected (guarded inside MQTTClient)
}
/// Tear down MQTT only if Follow-Me is not running.
func releaseMQTTIfIdle() {
guard !isStreaming else { return }
mqtt.disconnect()
}
/// Publish a ROS-compatible Twist cmd_vel command at QoS 0.
func publishCmdVel(linearX: Float, angularZ: Float) {
let payload = String(format: "{\"linear_x\":%.3f,\"angular_z\":%.3f}",
linearX, angularZ)
mqtt.publish(topic: "saltybot/cmd_vel", payload: payload)
}
// MARK: - Sensor lifecycle
private func ensureSensorsRunning() {