- 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>
224 lines
6.9 KiB
Swift
224 lines
6.9 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Root (tab container)
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject var sensor: SensorManager
|
|
|
|
var body: some View {
|
|
TabView {
|
|
StatusView()
|
|
.tabItem { Label("Status", systemImage: "antenna.radiowaves.left.and.right") }
|
|
MapContentView()
|
|
.tabItem { Label("Map", systemImage: "map.fill") }
|
|
BLEStatusView()
|
|
.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") }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Status tab
|
|
|
|
private struct StatusView: View {
|
|
@EnvironmentObject var sensor: SensorManager
|
|
@AppStorage("orinURL") private var orinURL: String = SensorManager.defaultOrinURL
|
|
@State private var editingURL: String = ""
|
|
@FocusState private var urlFieldFocused: Bool
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 24) {
|
|
connectionBanner
|
|
orinURLField
|
|
Divider()
|
|
sensorRatesGrid
|
|
Divider()
|
|
if let dist = sensor.distanceToRobot {
|
|
distanceRow(dist)
|
|
}
|
|
Spacer()
|
|
followMeButton
|
|
}
|
|
.padding()
|
|
.navigationTitle("SAUL-T-MOTE")
|
|
.onAppear { editingURL = orinURL }
|
|
}
|
|
}
|
|
|
|
// MARK: Connection banner
|
|
|
|
private var connectionBanner: some View {
|
|
HStack(spacing: 12) {
|
|
Circle()
|
|
.fill(wsColor)
|
|
.frame(width: 14, height: 14)
|
|
Text(wsLabel)
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
// MARK: Orin URL field
|
|
|
|
private var orinURLField: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Orin WebSocket URL")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
HStack {
|
|
TextField("ws://host:port", text: $editingURL)
|
|
.textFieldStyle(.roundedBorder)
|
|
.keyboardType(.URL)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.focused($urlFieldFocused)
|
|
.disabled(sensor.isStreaming)
|
|
.onSubmit { applyURL() }
|
|
if !sensor.isStreaming {
|
|
Button("Apply") { applyURL() }
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyURL() {
|
|
urlFieldFocused = false
|
|
orinURL = editingURL
|
|
sensor.updateURL(editingURL)
|
|
}
|
|
|
|
// MARK: Sensor rates grid
|
|
|
|
private var sensorRatesGrid: some View {
|
|
Grid(horizontalSpacing: 20, verticalSpacing: 12) {
|
|
GridRow {
|
|
rateCell(icon: "location.fill", label: "GPS", rate: sensor.gpsRate)
|
|
rateCell(icon: "gyroscope", label: "IMU", rate: sensor.imuRate)
|
|
}
|
|
GridRow {
|
|
rateCell(icon: "location.north.fill", label: "Heading", rate: sensor.headingRate)
|
|
rateCell(icon: "barometer", label: "Baro", rate: sensor.baroRate)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rateCell(icon: String, label: String, rate: Double) -> some View {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundStyle(rate > 0 ? .green : .secondary)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text("\(Int(rate)) Hz")
|
|
.font(.title3.monospacedDigit())
|
|
.bold()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
// MARK: Distance row
|
|
|
|
private func distanceRow(_ dist: Double) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: distanceIcon)
|
|
.foregroundStyle(distanceColor)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(distanceLabel(dist))
|
|
.font(.title2).bold()
|
|
Text(distanceSourceLabel)
|
|
.font(.caption).foregroundStyle(distanceColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func distanceLabel(_ dist: Double) -> String {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB, .mqttUWB:
|
|
return dist < 10
|
|
? String(format: "%.2f m", dist)
|
|
: String(format: "%.1f m", dist)
|
|
case .gps:
|
|
return dist < 1000
|
|
? "~\(Int(dist)) m"
|
|
: String(format: "~%.1f km", dist / 1000)
|
|
}
|
|
}
|
|
|
|
private var distanceSourceLabel: String {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB: return "UWB (BLE tag)"
|
|
case .mqttUWB: return "UWB (Orin anchors)"
|
|
case .gps: return "GPS estimate"
|
|
}
|
|
}
|
|
|
|
private var distanceIcon: String {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB, .mqttUWB: return "dot.radiowaves.left.and.right"
|
|
case .gps: return "location.fill"
|
|
}
|
|
}
|
|
|
|
private var distanceColor: Color {
|
|
switch sensor.distanceSource {
|
|
case .blueUWB: return .green
|
|
case .mqttUWB: return .mint
|
|
case .gps: return .orange
|
|
}
|
|
}
|
|
|
|
// MARK: Follow-Me button
|
|
|
|
private var followMeButton: some View {
|
|
Button {
|
|
sensor.isStreaming ? sensor.stopStreaming() : sensor.startStreaming()
|
|
} label: {
|
|
Label(
|
|
sensor.isStreaming ? "Stop Follow-Me" : "Start Follow-Me",
|
|
systemImage: sensor.isStreaming ? "stop.circle.fill" : "play.circle.fill"
|
|
)
|
|
.font(.title2.bold())
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(sensor.isStreaming ? Color.red : Color.accentColor,
|
|
in: RoundedRectangle(cornerRadius: 16))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private var wsColor: Color {
|
|
switch sensor.wsState {
|
|
case .connected: return .green
|
|
case .connecting: return .yellow
|
|
case .disconnected: return .red
|
|
}
|
|
}
|
|
|
|
private var wsLabel: String {
|
|
switch sensor.wsState {
|
|
case .connected: return "Connected — \(orinURL)"
|
|
case .connecting: return "Connecting…"
|
|
case .disconnected: return "Disconnected"
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.environmentObject(SensorManager())
|
|
}
|