feat: Merge SaltyTag BLE — GPS/IMU streaming to UWB tag, anchor display, UWB position authority #5
@ -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;
|
||||
};
|
||||
|
||||
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
187
SulTee/SulTee/PilotView.swift
Normal file
187
SulTee/SulTee/PilotView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user