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 */; };
|
A10000010000000000000DAA /* BLEPackets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000DAB /* BLEPackets.swift */; };
|
||||||
A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; };
|
A10000010000000000000EAA /* AnchorInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000EAB /* AnchorInfo.swift */; };
|
||||||
A10000010000000000000FAA /* BLEStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000FAB /* BLEStatusView.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@ -44,6 +45,7 @@
|
|||||||
A10000010000000000000DAB /* BLEPackets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEPackets.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -83,6 +85,7 @@
|
|||||||
A100000100000000000011BB /* RouteStore.swift */,
|
A100000100000000000011BB /* RouteStore.swift */,
|
||||||
A100000100000000000012BB /* RouteRecorder.swift */,
|
A100000100000000000012BB /* RouteRecorder.swift */,
|
||||||
A100000100000000000013BB /* RoutesView.swift */,
|
A100000100000000000013BB /* RoutesView.swift */,
|
||||||
|
A100000100000000000014AB /* PilotView.swift */,
|
||||||
A100000100000000000005AB /* Assets.xcassets */,
|
A100000100000000000005AB /* Assets.xcassets */,
|
||||||
A100000100000000000006AB /* Info.plist */,
|
A100000100000000000006AB /* Info.plist */,
|
||||||
);
|
);
|
||||||
@ -182,6 +185,7 @@
|
|||||||
A100000100000000000011AB /* RouteStore.swift in Sources */,
|
A100000100000000000011AB /* RouteStore.swift in Sources */,
|
||||||
A100000100000000000012AB /* RouteRecorder.swift in Sources */,
|
A100000100000000000012AB /* RouteRecorder.swift in Sources */,
|
||||||
A100000100000000000013AB /* RoutesView.swift in Sources */,
|
A100000100000000000013AB /* RoutesView.swift in Sources */,
|
||||||
|
A100000100000000000014AA /* PilotView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,6 +15,8 @@ struct ContentView: View {
|
|||||||
.tabItem { Label("BLE Tag", systemImage: "dot.radiowaves.right") }
|
.tabItem { Label("BLE Tag", systemImage: "dot.radiowaves.right") }
|
||||||
RoutesView()
|
RoutesView()
|
||||||
.tabItem { Label("Routes", systemImage: "point.bottomleft.forward.to.point.topright.scurvepath") }
|
.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()
|
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
|
// MARK: - Sensor lifecycle
|
||||||
|
|
||||||
private func ensureSensorsRunning() {
|
private func ensureSensorsRunning() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user