diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj index 67a3785..6092692 100644 --- a/SulTee/SulTee.xcodeproj/project.pbxproj +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -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 = ""; }; A10000010000000000000EAB /* AnchorInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorInfo.swift; sourceTree = ""; }; A10000010000000000000FAB /* BLEStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEStatusView.swift; sourceTree = ""; }; + A100000100000000000014AB /* PilotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PilotView.swift; sourceTree = ""; }; /* 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; }; diff --git a/SulTee/SulTee/ContentView.swift b/SulTee/SulTee/ContentView.swift index 35f3ba1..17f8556 100644 --- a/SulTee/SulTee/ContentView.swift +++ b/SulTee/SulTee/ContentView.swift @@ -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") } } } } diff --git a/SulTee/SulTee/PilotView.swift b/SulTee/SulTee/PilotView.swift new file mode 100644 index 0000000..b2746c6 --- /dev/null +++ b/SulTee/SulTee/PilotView.swift @@ -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 = """ + + + + + + + """ + 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) + } +} diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 47e3d63..96959cf 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -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() {