From 50096707f3ce61b75b3ca50ef56663ad26ea76a8 Mon Sep 17 00:00:00 2001 From: sl-ios Date: Fri, 3 Apr 2026 16:52:26 -0400 Subject: [PATCH 1/6] feat: iOS companion app - sensor streaming over WebSocket (Issue #709) - SulTee SwiftUI app targeting iOS 17+, iPhone 15 Pro - CoreLocation: dual-frequency GPS (L1+L5) continuous updates, background mode enabled - CoreMotion: 100 Hz IMU (accel + gyro + attitude + gravity), magnetometer via device motion - CMAltimeter: barometer relative altitude + pressure streaming - CLLocationManager heading updates for magnetometer heading - URLSessionWebSocketTask client connecting to ws://192.168.86.158:9090 - JSON protocol: {type, timestamp, data} for gps/imu/heading/baro messages - Auto-reconnect on disconnect (2s backoff) - Haptic feedback on incoming "haptic" messages from bot - Background streaming: UIBackgroundModes location + external-accessory in Info.plist - SwiftUI status UI: connection banner, sensor rate counters (Hz), start/stop follow-me button - Dev team Z37N597UWY (vayrette@gmail.com), bundle ID com.saltylab.sultee Co-Authored-By: Claude Sonnet 4.6 --- SulTee/SulTee.xcodeproj/project.pbxproj | 341 ++++++++++++++++++ .../AppIcon.appiconset/Contents.json | 13 + SulTee/SulTee/Assets.xcassets/Contents.json | 6 + SulTee/SulTee/ContentView.swift | 115 ++++++ SulTee/SulTee/Info.plist | 57 +++ SulTee/SulTee/SensorManager.swift | 230 ++++++++++++ SulTee/SulTee/SulTeeApp.swift | 13 + SulTee/SulTee/WebSocketClient.swift | 114 ++++++ 8 files changed, 889 insertions(+) create mode 100644 SulTee/SulTee.xcodeproj/project.pbxproj create mode 100644 SulTee/SulTee/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 SulTee/SulTee/Assets.xcassets/Contents.json create mode 100644 SulTee/SulTee/ContentView.swift create mode 100644 SulTee/SulTee/Info.plist create mode 100644 SulTee/SulTee/SensorManager.swift create mode 100644 SulTee/SulTee/SulTeeApp.swift create mode 100644 SulTee/SulTee/WebSocketClient.swift diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c0193ec --- /dev/null +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -0,0 +1,341 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A100000100000000000001AA /* SulTeeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000001AB /* SulTeeApp.swift */; }; + A100000100000000000002AA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000002AB /* ContentView.swift */; }; + A100000100000000000003AA /* SensorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000003AB /* SensorManager.swift */; }; + A100000100000000000004AA /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000004AB /* WebSocketClient.swift */; }; + A100000100000000000005AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000100000000000005AB /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A100000100000000000001AB /* SulTeeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SulTeeApp.swift; sourceTree = ""; }; + A100000100000000000002AB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A100000100000000000003AB /* SensorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorManager.swift; sourceTree = ""; }; + A100000100000000000004AB /* WebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketClient.swift; sourceTree = ""; }; + A100000100000000000005AB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A100000100000000000006AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A100000100000000000007AB /* SulTee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SulTee.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000010000000000000FBP /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A100000100000000000010GR /* / */ = { + isa = PBXGroup; + children = ( + A100000100000000000011GR /* SulTee */, + A100000100000000000012GR /* Products */, + ); + sourceTree = ""; + }; + A100000100000000000011GR /* SulTee */ = { + isa = PBXGroup; + children = ( + A100000100000000000001AB /* SulTeeApp.swift */, + A100000100000000000002AB /* ContentView.swift */, + A100000100000000000003AB /* SensorManager.swift */, + A100000100000000000004AB /* WebSocketClient.swift */, + A100000100000000000005AB /* Assets.xcassets */, + A100000100000000000006AB /* Info.plist */, + ); + path = SulTee; + sourceTree = ""; + }; + A100000100000000000012GR /* Products */ = { + isa = PBXGroup; + children = ( + A100000100000000000007AB /* SulTee.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A100000100000000000001TG /* SulTee */ = { + isa = PBXNativeTarget; + buildConfigurationList = A100000100000000000001CL /* Build configuration list for PBXNativeTarget "SulTee" */; + buildPhases = ( + A10000010000000000000DBP /* Sources */, + A10000010000000000000EBP /* Resources */, + A10000010000000000000FBP /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SulTee; + productName = SulTee; + productReference = A100000100000000000007AB /* SulTee.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A100000100000000000001PJ /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + ORGANIZATIONNAME = "SaltyLab"; + TargetAttributes = { + A100000100000000000001TG = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = A100000100000000000002CL /* Build configuration list for PBXProject "SulTee" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A100000100000000000010GR /* / */; + productRefGroup = A100000100000000000012GR /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A100000100000000000001TG /* SulTee */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000010000000000000EBP /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A100000100000000000005AA /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000010000000000000DBP /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A100000100000000000001AA /* SulTeeApp.swift in Sources */, + A100000100000000000002AA /* ContentView.swift in Sources */, + A100000100000000000003AA /* SensorManager.swift in Sources */, + A100000100000000000004AA /* WebSocketClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A100000100000000000001BC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_CYCLE = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A100000100000000000002BC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_CYCLE = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A100000100000000000003BC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = Z37N597UWY; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = SulTee/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.saltylab.sultee; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A100000100000000000004BC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = Z37N597UWY; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = SulTee/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.saltylab.sultee; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A100000100000000000002CL /* Build configuration list for PBXProject "SulTee" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A100000100000000000001BC /* Debug */, + A100000100000000000002BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A100000100000000000001CL /* Build configuration list for PBXNativeTarget "SulTee" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A100000100000000000003BC /* Debug */, + A100000100000000000004BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + + }; + rootObject = A100000100000000000001PJ /* Project object */; +} diff --git a/SulTee/SulTee/Assets.xcassets/AppIcon.appiconset/Contents.json b/SulTee/SulTee/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/SulTee/SulTee/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SulTee/SulTee/Assets.xcassets/Contents.json b/SulTee/SulTee/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SulTee/SulTee/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SulTee/SulTee/ContentView.swift b/SulTee/SulTee/ContentView.swift new file mode 100644 index 0000000..14741c8 --- /dev/null +++ b/SulTee/SulTee/ContentView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var sensor: SensorManager + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + connectionBanner + Divider() + sensorRatesGrid + Divider() + if let dist = sensor.botDistanceMeters { + Text("Bot distance: \(dist, specifier: "%.1f") m") + .font(.title2) + .bold() + } + Spacer() + followMeButton + } + .padding() + .navigationTitle("Sul-Tee") + } + } + + // MARK: - Subviews + + private var connectionBanner: some View { + HStack(spacing: 12) { + Circle() + .fill(wsColor) + .frame(width: 14, height: 14) + Text(wsLabel) + .font(.headline) + Spacer() + } + .padding(.top, 8) + } + + private var sensorRatesGrid: some View { + Grid(horizontalSpacing: 20, verticalSpacing: 12) { + GridRow { + rateCell(icon: "location.fill", label: "GPS", rate: sensor.gpsRate, unit: "Hz") + rateCell(icon: "gyroscope", label: "IMU", rate: sensor.imuRate, unit: "Hz") + } + GridRow { + rateCell(icon: "location.north.fill", label: "Heading", rate: sensor.headingRate, unit: "Hz") + rateCell(icon: "barometer", label: "Baro", rate: sensor.baroRate, unit: "Hz") + } + } + } + + private func rateCell(icon: String, label: String, rate: Double, unit: String) -> some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(rate > 0 ? .green : .secondary) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(Int(rate)) \(unit)") + .font(.title3.monospacedDigit()) + .bold() + } + .frame(maxWidth: .infinity) + .padding() + .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + } + + private var followMeButton: some View { + Button { + if sensor.isStreaming { + sensor.stopStreaming() + } else { + 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 — ws://192.168.86.158:9090" + case .connecting: return "Connecting…" + case .disconnected: return "Disconnected" + } + } +} + +#Preview { + ContentView() + .environmentObject(SensorManager()) +} diff --git a/SulTee/SulTee/Info.plist b/SulTee/SulTee/Info.plist new file mode 100644 index 0000000..1119bba --- /dev/null +++ b/SulTee/SulTee/Info.plist @@ -0,0 +1,57 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSLocationAlwaysAndWhenInUseUsageDescription + Sul-Tee streams your GPS location continuously to SaltyBot for follow-me mode. Background location is required to keep streaming when the phone is locked. + NSLocationWhenInUseUsageDescription + Sul-Tee streams your GPS location to SaltyBot for follow-me mode. + NSMotionUsageDescription + Sul-Tee streams IMU and barometer data to SaltyBot for follow-me stabilization. + UIBackgroundModes + + location + external-accessory + + UIDeviceFamily + + 1 + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift new file mode 100644 index 0000000..654cb77 --- /dev/null +++ b/SulTee/SulTee/SensorManager.swift @@ -0,0 +1,230 @@ +import Foundation +import CoreLocation +import CoreMotion +import Combine + +/// Manages all iPhone sensors and forwards data to the WebSocket client. +final class SensorManager: NSObject, ObservableObject { + + // MARK: - Published state for UI + + @Published var isStreaming = false + @Published var wsState: WebSocketClient.ConnectionState = .disconnected + @Published var gpsRate: Double = 0 + @Published var imuRate: Double = 0 + @Published var headingRate: Double = 0 + @Published var baroRate: Double = 0 + @Published var botDistanceMeters: Double? = nil + + // MARK: - Dependencies + + private let ws = WebSocketClient(url: URL(string: "ws://192.168.86.158:9090")!) + private let locationManager = CLLocationManager() + private let motionManager = CMMotionManager() + private let altimeter = CMAltimeter() + private var cancellables = Set() + + // MARK: - Rate counters (counts per second) + + private var gpsCounts: [Date] = [] + private var imuCounts: [Date] = [] + private var headingCounts: [Date] = [] + private var baroCounts: [Date] = [] + private var rateTimer: Timer? + + // MARK: - Init + + override init() { + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation + // Use dual-frequency GPS (L1+L5) on iPhone 15 Pro — automatic when accuracy is set to Best + locationManager.distanceFilter = kCLDistanceFilterNone + locationManager.allowsBackgroundLocationUpdates = true + locationManager.pausesLocationUpdatesAutomatically = false + locationManager.showsBackgroundLocationIndicator = true + + ws.$state + .receive(on: DispatchQueue.main) + .assign(to: \.wsState, on: self) + .store(in: &cancellables) + } + + // MARK: - Public control + + func startStreaming() { + guard !isStreaming else { return } + isStreaming = true + ws.connect() + requestPermissionsAndStartSensors() + startRateTimer() + } + + func stopStreaming() { + guard isStreaming else { return } + isStreaming = false + ws.disconnect() + stopSensors() + rateTimer?.invalidate() + rateTimer = nil + } + + // MARK: - Sensor start / stop + + private func requestPermissionsAndStartSensors() { + locationManager.requestAlwaysAuthorization() + locationManager.startUpdatingLocation() + locationManager.startUpdatingHeading() + startIMU() + startBarometer() + } + + private func stopSensors() { + locationManager.stopUpdatingLocation() + locationManager.stopUpdatingHeading() + motionManager.stopDeviceMotionUpdates() + altimeter.stopRelativeAltitudeUpdates() + } + + private func startIMU() { + guard motionManager.isDeviceMotionAvailable else { return } + motionManager.deviceMotionUpdateInterval = 1.0 / 100.0 // 100 Hz + motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in + guard let self, let motion else { return } + self.recordEvent(in: &self.imuCounts) + let ts = Date().timeIntervalSince1970 + self.ws.send([ + "type": "imu", + "timestamp": ts, + "data": [ + "accel": [ + "x": motion.userAcceleration.x, + "y": motion.userAcceleration.y, + "z": motion.userAcceleration.z + ], + "gyro": [ + "x": motion.rotationRate.x, + "y": motion.rotationRate.y, + "z": motion.rotationRate.z + ], + "attitude": [ + "roll": motion.attitude.roll, + "pitch": motion.attitude.pitch, + "yaw": motion.attitude.yaw + ], + "gravity": [ + "x": motion.gravity.x, + "y": motion.gravity.y, + "z": motion.gravity.z + ], + "magneticField": [ + "x": motion.magneticField.field.x, + "y": motion.magneticField.field.y, + "z": motion.magneticField.field.z, + "accuracy": motion.magneticField.accuracy.rawValue + ] + ] + ]) + } + } + + private func startBarometer() { + guard CMAltimeter.isRelativeAltitudeAvailable() else { return } + altimeter.startRelativeAltitudeUpdates(to: .main) { [weak self] data, error in + guard let self, let data else { return } + self.recordEvent(in: &self.baroCounts) + self.ws.send([ + "type": "baro", + "timestamp": Date().timeIntervalSince1970, + "data": [ + "relativeAltitude": data.relativeAltitude.doubleValue, + "pressure": data.pressure.doubleValue + ] + ]) + } + } + + // MARK: - Rate tracking + + private func startRateTimer() { + rateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateRates() + } + } + + private func recordEvent(in list: inout [Date]) { + list.append(Date()) + } + + private func updateRates() { + let cutoff = Date().addingTimeInterval(-1.0) + gpsCounts = gpsCounts.filter { $0 > cutoff } + imuCounts = imuCounts.filter { $0 > cutoff } + headingCounts = headingCounts.filter { $0 > cutoff } + baroCounts = baroCounts.filter { $0 > cutoff } + + DispatchQueue.main.async { + self.gpsRate = Double(self.gpsCounts.count) + self.imuRate = Double(self.imuCounts.count) + self.headingRate = Double(self.headingCounts.count) + self.baroRate = Double(self.baroCounts.count) + } + } +} + +// MARK: - CLLocationManagerDelegate + +extension SensorManager: CLLocationManagerDelegate { + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + recordEvent(in: &gpsCounts) + ws.send([ + "type": "gps", + "timestamp": loc.timestamp.timeIntervalSince1970, + "data": [ + "latitude": loc.coordinate.latitude, + "longitude": loc.coordinate.longitude, + "altitude": loc.altitude, + "horizontalAccuracy": loc.horizontalAccuracy, + "verticalAccuracy": loc.verticalAccuracy, + "speed": loc.speed, + "speedAccuracy": loc.speedAccuracy, + "course": loc.course, + "courseAccuracy": loc.courseAccuracy + ] + ]) + } + + func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + recordEvent(in: &headingCounts) + ws.send([ + "type": "heading", + "timestamp": Date().timeIntervalSince1970, + "data": [ + "magneticHeading": newHeading.magneticHeading, + "trueHeading": newHeading.trueHeading, + "headingAccuracy": newHeading.headingAccuracy, + "x": newHeading.x, + "y": newHeading.y, + "z": newHeading.z + ] + ]) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("[Location] error: \(error)") + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + if isStreaming { + manager.startUpdatingLocation() + manager.startUpdatingHeading() + } + default: + break + } + } +} diff --git a/SulTee/SulTee/SulTeeApp.swift b/SulTee/SulTee/SulTeeApp.swift new file mode 100644 index 0000000..30738a7 --- /dev/null +++ b/SulTee/SulTee/SulTeeApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct SulTeeApp: App { + @StateObject private var sensorManager = SensorManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(sensorManager) + } + } +} diff --git a/SulTee/SulTee/WebSocketClient.swift b/SulTee/SulTee/WebSocketClient.swift new file mode 100644 index 0000000..d898d5c --- /dev/null +++ b/SulTee/SulTee/WebSocketClient.swift @@ -0,0 +1,114 @@ +import Foundation +import UIKit + +/// Thin WebSocket wrapper around URLSessionWebSocketTask. +/// Reconnects automatically on disconnect. +final class WebSocketClient: NSObject, ObservableObject { + + enum ConnectionState { + case disconnected, connecting, connected + } + + @Published var state: ConnectionState = .disconnected + + private let url: URL + private var session: URLSession! + private var task: URLSessionWebSocketTask? + private var shouldRun = false + + init(url: URL) { + self.url = url + super.init() + self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + } + + func connect() { + shouldRun = true + guard state == .disconnected else { return } + openConnection() + } + + func disconnect() { + shouldRun = false + task?.cancel(with: .normalClosure, reason: nil) + task = nil + DispatchQueue.main.async { self.state = .disconnected } + } + + func send(_ message: [String: Any]) { + guard state == .connected, + let data = try? JSONSerialization.data(withJSONObject: message), + let json = String(data: data, encoding: .utf8) else { return } + + task?.send(.string(json)) { error in + if let error { + print("[WebSocket] send error: \(error)") + } + } + } + + // MARK: - Private + + private func openConnection() { + DispatchQueue.main.async { self.state = .connecting } + task = session.webSocketTask(with: url) + task?.resume() + scheduleReceive() + } + + private func scheduleReceive() { + task?.receive { [weak self] result in + guard let self else { return } + switch result { + case .success(let message): + self.handle(message) + self.scheduleReceive() + case .failure(let error): + print("[WebSocket] receive error: \(error)") + self.reconnectIfNeeded() + } + } + } + + private func handle(_ message: URLSessionWebSocketTask.Message) { + switch message { + case .string(let text): + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String else { return } + + if type == "haptic" { + DispatchQueue.main.async { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + default: + break + } + } + + private func reconnectIfNeeded() { + DispatchQueue.main.async { self.state = .disconnected } + guard shouldRun else { return } + DispatchQueue.global().asyncAfter(deadline: .now() + 2) { [weak self] in + self?.openConnection() + } + } +} + +// MARK: - URLSessionWebSocketDelegate + +extension WebSocketClient: URLSessionWebSocketDelegate { + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + DispatchQueue.main.async { self.state = .connected } + } + + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data?) { + reconnectIfNeeded() + } +} -- 2.47.2 From 433d85754f4919a5d2e89e64a7f6e70ddea3495a Mon Sep 17 00:00:00 2001 From: sl-ios Date: Fri, 3 Apr 2026 17:24:41 -0400 Subject: [PATCH 2/6] fix: switch WebSocket to Tailscale IP, add configurable Orin URL (Issue #709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default URL updated from ws://192.168.86.158:9090 (LAN) to ws://100.64.0.2:9090 (Tailscale) - URL persisted in UserDefaults under key "orinURL" — survives app restarts - WebSocketClient.url is now mutable so it can be updated without recreation - SensorManager.updateURL(_:) applies a new URL when not streaming - ContentView: editable text field for Orin address with Apply button, disabled while streaming - Connection banner shows the active URL instead of hardcoded string Co-Authored-By: Claude Sonnet 4.6 --- SulTee/SulTee/ContentView.swift | 36 ++++++++++++++++++++++++++++- SulTee/SulTee/SensorManager.swift | 23 +++++++++++++++++- SulTee/SulTee/WebSocketClient.swift | 2 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/SulTee/SulTee/ContentView.swift b/SulTee/SulTee/ContentView.swift index 14741c8..c9b1ad0 100644 --- a/SulTee/SulTee/ContentView.swift +++ b/SulTee/SulTee/ContentView.swift @@ -2,11 +2,15 @@ import SwiftUI struct ContentView: 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() @@ -20,11 +24,41 @@ struct ContentView: View { } .padding() .navigationTitle("Sul-Tee") + .onAppear { editingURL = orinURL } } } // MARK: - Subviews + 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) + } + private var connectionBanner: some View { HStack(spacing: 12) { Circle() @@ -102,7 +136,7 @@ struct ContentView: View { private var wsLabel: String { switch sensor.wsState { - case .connected: return "Connected — ws://192.168.86.158:9090" + case .connected: return "Connected — \(orinURL)" case .connecting: return "Connecting…" case .disconnected: return "Disconnected" } diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 654cb77..70531e2 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -18,7 +18,16 @@ final class SensorManager: NSObject, ObservableObject { // MARK: - Dependencies - private let ws = WebSocketClient(url: URL(string: "ws://192.168.86.158:9090")!) + static let defaultOrinURL = "ws://100.64.0.2:9090" + private static let orinURLKey = "orinURL" + + private(set) var ws: WebSocketClient + + /// Current Orin WebSocket URL string (persisted in UserDefaults). + var orinURLString: String { + get { UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL } + set { UserDefaults.standard.set(newValue, forKey: Self.orinURLKey) } + } private let locationManager = CLLocationManager() private let motionManager = CMMotionManager() private let altimeter = CMAltimeter() @@ -35,6 +44,8 @@ final class SensorManager: NSObject, ObservableObject { // MARK: - Init override init() { + let urlString = UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL + self.ws = WebSocketClient(url: URL(string: urlString) ?? URL(string: Self.defaultOrinURL)!) super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation @@ -69,6 +80,16 @@ final class SensorManager: NSObject, ObservableObject { rateTimer = nil } + /// Call when the user edits the Orin URL. Persists the value and updates + /// the client URL; takes effect on the next connect(). + func updateURL(_ urlString: String) { + guard !isStreaming else { return } + orinURLString = urlString + if let url = URL(string: urlString), url.scheme?.hasPrefix("ws") == true { + ws.url = url + } + } + // MARK: - Sensor start / stop private func requestPermissionsAndStartSensors() { diff --git a/SulTee/SulTee/WebSocketClient.swift b/SulTee/SulTee/WebSocketClient.swift index d898d5c..5c8e2e9 100644 --- a/SulTee/SulTee/WebSocketClient.swift +++ b/SulTee/SulTee/WebSocketClient.swift @@ -11,7 +11,7 @@ final class WebSocketClient: NSObject, ObservableObject { @Published var state: ConnectionState = .disconnected - private let url: URL + var url: URL private var session: URLSession! private var task: URLSessionWebSocketTask? private var shouldRun = false -- 2.47.2 From 19c05516df3aa79e1c2b8afe1f28f759854d7ce5 Mon Sep 17 00:00:00 2001 From: sl-ios Date: Fri, 3 Apr 2026 18:02:18 -0400 Subject: [PATCH 3/6] fix: add ATS exception for Tailscale WebSocket (error -1022) (Issue #709) NSAllowsLocalNetworking covers CGNAT 100.64.0.0/10 range used by Tailscale, fixing ATS blocking plain ws:// connections. Also adds NSExceptionDomains entry for 100.64.0.2 as explicit fallback. Co-Authored-By: Claude Sonnet 4.6 --- SulTee/SulTee/Info.plist | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/SulTee/SulTee/Info.plist b/SulTee/SulTee/Info.plist index 1119bba..40874c4 100644 --- a/SulTee/SulTee/Info.plist +++ b/SulTee/SulTee/Info.plist @@ -48,6 +48,22 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity + + + NSAllowsLocalNetworking + + NSExceptionDomains + + 100.64.0.2 + + NSExceptionAllowsInsecureHTTPLoads + + + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes -- 2.47.2 From 541a27b07b9b5888b26bf4dbc4a7140e297f0372 Mon Sep 17 00:00:00 2001 From: sl-ios Date: Sat, 4 Apr 2026 11:11:11 -0400 Subject: [PATCH 4/6] feat: publish iOS GPS to MQTT topic saltybot/ios/gps at 1 Hz (Issue #681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds minimal MQTT 3.1.1 client (MQTTClient.swift) using Network.framework — no external dependency. Implements CONNECT + PUBLISH (QoS 0) + PINGREQ keepalive. - Broker: 192.168.87.29:1883 (user: mqtt_seb) - Topic: saltybot/ios/gps - Rate: 1 Hz Timer, decoupled from GPS update rate - Payload matches sensor_dashboard.py format: {ts, lat, lon, alt_m, accuracy_m, speed_ms, bearing_deg, provider: "gps"} - lastKnownLocation cached from CLLocationManagerDelegate, published on timer - MQTT connect/disconnect tied to startStreaming()/stopStreaming() - ATS NSExceptionDomains extended to include 192.168.87.29 (MQTT broker LAN IP) - MQTTClient.swift registered in project.pbxproj Sources build phase Co-Authored-By: Claude Sonnet 4.6 --- SulTee/SulTee.xcodeproj/project.pbxproj | 4 + SulTee/SulTee/Info.plist | 5 + SulTee/SulTee/MQTTClient.swift | 187 ++++++++++++++++++++++++ SulTee/SulTee/SensorManager.swift | 52 ++++++- 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 SulTee/SulTee/MQTTClient.swift diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj index c0193ec..c3a2c72 100644 --- a/SulTee/SulTee.xcodeproj/project.pbxproj +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ A100000100000000000003AA /* SensorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000003AB /* SensorManager.swift */; }; A100000100000000000004AA /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000004AB /* WebSocketClient.swift */; }; A100000100000000000005AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000100000000000005AB /* Assets.xcassets */; }; + A100000100000000000009AA /* MQTTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000009AB /* MQTTClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,6 +23,7 @@ A100000100000000000005AB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A100000100000000000006AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A100000100000000000007AB /* SulTee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SulTee.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A100000100000000000009AB /* MQTTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTClient.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,6 +52,7 @@ A100000100000000000002AB /* ContentView.swift */, A100000100000000000003AB /* SensorManager.swift */, A100000100000000000004AB /* WebSocketClient.swift */, + A100000100000000000009AB /* MQTTClient.swift */, A100000100000000000005AB /* Assets.xcassets */, A100000100000000000006AB /* Info.plist */, ); @@ -138,6 +141,7 @@ A100000100000000000002AA /* ContentView.swift in Sources */, A100000100000000000003AA /* SensorManager.swift in Sources */, A100000100000000000004AA /* WebSocketClient.swift in Sources */, + A100000100000000000009AA /* MQTTClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SulTee/SulTee/Info.plist b/SulTee/SulTee/Info.plist index 40874c4..8e3361b 100644 --- a/SulTee/SulTee/Info.plist +++ b/SulTee/SulTee/Info.plist @@ -62,6 +62,11 @@ NSExceptionAllowsInsecureHTTPLoads + 192.168.87.29 + + NSExceptionAllowsInsecureHTTPLoads + + UIApplicationSceneManifest diff --git a/SulTee/SulTee/MQTTClient.swift b/SulTee/SulTee/MQTTClient.swift new file mode 100644 index 0000000..88df9bc --- /dev/null +++ b/SulTee/SulTee/MQTTClient.swift @@ -0,0 +1,187 @@ +import Foundation +import Network + +/// Minimal MQTT 3.1.1 client — CONNECT + PUBLISH (QoS 0) + PINGREQ only. +/// Sufficient for 1 Hz telemetry publishing; no subscription support needed. +final class MQTTClient { + + struct Config { + var host: String + var port: UInt16 + var username: String + var password: String + var clientID: String + var keepAlive: UInt16 = 60 + } + + enum State { case disconnected, connecting, connected } + + private(set) var state: State = .disconnected + private var config: Config + private var connection: NWConnection? + private var pingTimer: DispatchSourceTimer? + private var shouldRun = false + private let queue = DispatchQueue(label: "mqtt.client", qos: .utility) + + init(config: Config) { + self.config = config + } + + // MARK: - Public + + func connect() { + shouldRun = true + guard state == .disconnected else { return } + openConnection() + } + + func disconnect() { + shouldRun = false + pingTimer?.cancel() + pingTimer = nil + connection?.cancel() + connection = nil + state = .disconnected + } + + /// Publish a UTF-8 string payload to `topic` at QoS 0. + func publish(topic: String, payload: String) { + guard state == .connected else { return } + let packet = buildPublish(topic: topic, payload: payload) + connection?.send(content: packet, completion: .idempotent) + } + + // MARK: - Connection lifecycle + + private func openConnection() { + state = .connecting + let host = NWEndpoint.Host(config.host) + let port = NWEndpoint.Port(rawValue: config.port)! + connection = NWConnection(host: host, port: port, using: .tcp) + connection?.stateUpdateHandler = { [weak self] newState in + self?.handleStateChange(newState) + } + connection?.start(queue: queue) + scheduleRead() + } + + private func handleStateChange(_ newState: NWConnection.State) { + switch newState { + case .ready: + sendConnect() + schedulePing() + case .failed, .cancelled: + state = .disconnected + pingTimer?.cancel() + pingTimer = nil + reconnectIfNeeded() + default: + break + } + } + + private func reconnectIfNeeded() { + guard shouldRun else { return } + queue.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.openConnection() + } + } + + // MARK: - Read loop (to receive CONNACK / PINGRESP) + + private func scheduleRead() { + connection?.receive(minimumIncompleteLength: 2, maximumLength: 256) { [weak self] data, _, _, error in + guard let self else { return } + if let data, !data.isEmpty { + self.handleIncoming(data) + } + if error == nil { self.scheduleRead() } + } + } + + private func handleIncoming(_ data: Data) { + guard let first = data.first else { return } + switch first { + case 0x20: // CONNACK + state = .connected + case 0xD0: // PINGRESP — no action needed + break + default: + break + } + } + + // MARK: - Keep-alive ping + + private func schedulePing() { + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now() + Double(config.keepAlive / 2), + repeating: Double(config.keepAlive / 2)) + t.setEventHandler { [weak self] in + self?.sendPing() + } + t.resume() + pingTimer = t + } + + private func sendPing() { + let packet = Data([0xC0, 0x00]) + connection?.send(content: packet, completion: .idempotent) + } + + // MARK: - MQTT packet builders + + private func sendConnect() { + let packet = buildConnect() + connection?.send(content: packet, completion: .idempotent) + } + + private func buildConnect() -> Data { + var payload = Data() + payload += mqttString("MQTT") // protocol name + payload.append(0x04) // protocol level (3.1.1) + payload.append(0xC2) // flags: username + password + clean session + payload += uint16BE(config.keepAlive) // keep-alive + payload += mqttString(config.clientID) // client ID + payload += mqttString(config.username) // username + payload += mqttString(config.password) // password + return mqttPacket(type: 0x10, payload: payload) + } + + private func buildPublish(topic: String, payload: String) -> Data { + var body = Data() + body += mqttString(topic) + body += payload.data(using: .utf8) ?? Data() + return mqttPacket(type: 0x30, payload: body) + } + + // MARK: - Encoding helpers + + private func mqttPacket(type: UInt8, payload: Data) -> Data { + var packet = Data([type]) + packet += remainingLength(payload.count) + packet += payload + return packet + } + + private func remainingLength(_ value: Int) -> Data { + var data = Data() + var n = value + repeat { + var byte = UInt8(n & 0x7F) + n >>= 7 + if n > 0 { byte |= 0x80 } + data.append(byte) + } while n > 0 + return data + } + + private func mqttString(_ s: String) -> Data { + let bytes = s.data(using: .utf8) ?? Data() + return uint16BE(UInt16(bytes.count)) + bytes + } + + private func uint16BE(_ v: UInt16) -> Data { + Data([UInt8(v >> 8), UInt8(v & 0xFF)]) + } +} diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 70531e2..9604f0a 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -3,7 +3,8 @@ import CoreLocation import CoreMotion import Combine -/// Manages all iPhone sensors and forwards data to the WebSocket client. +/// Manages all iPhone sensors and forwards data to the WebSocket client +/// and MQTT broker (topic: saltybot/ios/gps, 1 Hz). final class SensorManager: NSObject, ObservableObject { // MARK: - Published state for UI @@ -16,7 +17,7 @@ final class SensorManager: NSObject, ObservableObject { @Published var baroRate: Double = 0 @Published var botDistanceMeters: Double? = nil - // MARK: - Dependencies + // MARK: - WebSocket static let defaultOrinURL = "ws://100.64.0.2:9090" private static let orinURLKey = "orinURL" @@ -28,6 +29,22 @@ final class SensorManager: NSObject, ObservableObject { get { UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL } set { UserDefaults.standard.set(newValue, forKey: Self.orinURLKey) } } + + // MARK: - MQTT + + private let mqtt = MQTTClient(config: .init( + host: "192.168.87.29", + port: 1883, + username: "mqtt_seb", + password: "mqtt_pass", + clientID: "sultee-ios-\(UUID().uuidString.prefix(8))" + )) + private static let mqttGPSTopic = "saltybot/ios/gps" + private var lastKnownLocation: CLLocation? + private var mqttPublishTimer: Timer? + + // MARK: - Sensors + private let locationManager = CLLocationManager() private let motionManager = CMMotionManager() private let altimeter = CMAltimeter() @@ -67,17 +84,22 @@ final class SensorManager: NSObject, ObservableObject { guard !isStreaming else { return } isStreaming = true ws.connect() + mqtt.connect() requestPermissionsAndStartSensors() startRateTimer() + startMQTTPublishTimer() } func stopStreaming() { guard isStreaming else { return } isStreaming = false ws.disconnect() + mqtt.disconnect() stopSensors() rateTimer?.invalidate() rateTimer = nil + mqttPublishTimer?.invalidate() + mqttPublishTimer = nil } /// Call when the user edits the Orin URL. Persists the value and updates @@ -90,6 +112,31 @@ final class SensorManager: NSObject, ObservableObject { } } + // MARK: - MQTT GPS publish (1 Hz) + + private func startMQTTPublishTimer() { + mqttPublishTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.publishGPSToMQTT() + } + } + + private func publishGPSToMQTT() { + guard let loc = lastKnownLocation else { return } + let payload: [String: Any] = [ + "ts": loc.timestamp.timeIntervalSince1970, + "lat": loc.coordinate.latitude, + "lon": loc.coordinate.longitude, + "alt_m": loc.altitude, + "accuracy_m": max(0, loc.horizontalAccuracy), + "speed_ms": max(0, loc.speed), + "bearing_deg": loc.course >= 0 ? loc.course : 0.0, + "provider": "gps" + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + mqtt.publish(topic: Self.mqttGPSTopic, payload: json) + } + // MARK: - Sensor start / stop private func requestPermissionsAndStartSensors() { @@ -199,6 +246,7 @@ extension SensorManager: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } + lastKnownLocation = loc recordEvent(in: &gpsCounts) ws.send([ "type": "gps", -- 2.47.2 From 72e3138fb3118bae01c77cf15936aa33356256ac Mon Sep 17 00:00:00 2001 From: sl-ios Date: Sat, 4 Apr 2026 11:41:11 -0400 Subject: [PATCH 5/6] feat: Rename to SAUL-T-MOTE, add map with user + robot positions and follow path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename: - CFBundleDisplayName = "SAUL-T-MOTE" in Info.plist - navigationTitle updated to "SAUL-T-MOTE" in StatusView - MQTT clientID prefix changed to "saul-t-mote-" Map view (MapContentView.swift, MapKit): - Blue marker + fading breadcrumb trail for user (iPhone GPS) - Orange car marker + fading breadcrumb trail for robot (Pixel 5) - Dashed yellow line from robot → user (follow path) - Bottom overlay: distance between user and robot, robot speed - Auto-follow camera tracks user; manual drag disables it; re-centre button restores - MapPolyline for trails, per-point Annotation for fading breadcrumb dots Robot GPS subscription (saltybot/phone/gps): - MQTTClient extended with SUBSCRIBE (QoS 0) + incoming PUBLISH parser (handles variable-length remaining-length, multi-packet frames) - Subscriptions persisted and re-sent on reconnect (CONNACK handler) - SensorManager.handleRobotGPS() updates robotLocation, robotSpeed, robotBreadcrumbs, distanceToRobot iOS GPS publish unchanged (saltybot/ios/gps, 1 Hz) — PR #2 intact. ContentView restructured as TabView: - Tab 1: Status (sensor rates, WS URL, follow-me button) - Tab 2: Map Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 67 ++++++ SulTee/SulTee.xcodeproj/project.pbxproj | 4 + .../contents.xcworkspacedata | 7 + SulTee/SulTee/ContentView.swift | 87 +++++--- SulTee/SulTee/Info.plist | 2 + SulTee/SulTee/MQTTClient.swift | 137 ++++++++---- SulTee/SulTee/MapContentView.swift | 184 ++++++++++++++++ SulTee/SulTee/SensorManager.swift | 196 ++++++++++-------- 8 files changed, 531 insertions(+), 153 deletions(-) create mode 100644 CLAUDE.md create mode 100644 SulTee/SulTee.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 SulTee/SulTee/MapContentView.swift diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3fb1c23 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# sl-ios — iOS Companion App Agent (Sul-Tee) + +## Role +You are sl-ios, a SaltyLab agent building the iOS companion app ("Sul-Tee") for SaltyBot follow-me mode. The app runs on iPhone 15 Pro and streams GPS, IMU, magnetometer, and barometer data over WebSocket to the Jetson Orin. + +## Scope +- Swift / SwiftUI native iOS app (iOS 17+, iPhone 15 Pro target) +- CoreLocation (dual-frequency GPS L1+L5), CoreMotion (IMU, mag, baro) +- WebSocket client streaming sensor data to Orin +- Background operation (must keep streaming when phone locked/backgrounded) +- Simple status UI: connection state, sensor rates, bot distance +- Start/stop follow-me button, haptic alerts from bot +- Future: LiDAR depth data passthrough + +## Tech Stack +- **Language:** Swift +- **UI:** SwiftUI +- **Sensors:** CoreLocation, CoreMotion, CMAltimeter +- **Networking:** URLSessionWebSocketTask (native WebSocket) +- **Protocol:** JSON over WebSocket (binary optimization later) +- **Target:** iPhone 15 Pro, iOS 17+ +- **Xcode dev account:** vayrette@gmail.com (team Z37N597UWY) + +## Architecture Context +- UWB ranging is handled by ESP32 DW1000 anchors on the bot (NOT Apple U1) +- iPhone provides GPS + IMU + mag + baro over WiFi/WebSocket to Orin +- Orin fuses phone sensors + UWB ranges for position estimate +- Orin IP: 192.168.86.158 (saltylab-orin) + +## Repository +- Repo: `seb/saltylab-ios` on gitea.vayrette.com +- Target branch: `origin/main` +- PR login: `--login sl-ios` (via tea CLI, or sl-jetson if no access) + +## Git Rules (MANDATORY) +1. Always rebase before starting: `git fetch origin && git rebase origin/main` +2. Always rebase before pushing: `git fetch origin && git rebase origin/main` +3. Branch naming: `sl-ios/issue--` + +## MQTT Communication +```bash +# Send message to max (PM): +AGENT_NAME=sl-ios ~/agent-mqtt/agent-send max "your message" + +# Read inbox: +~/agent-mqtt/agent-read 2>/dev/null | tail -15 +``` +Prioritize messages from max in your inbox. + +## PR Workflow +```bash +tea pr create --login sl-ios --repo seb/saltylab-ios \ + --title 'feat: (Issue #N)' \ + --description '
' --base main +``` +If push fails (permission denied), report via MQTT — sl-jetson will push for you. + +## Tab Naming +Update iTerm tab to reflect state: +- Working: `printf '\e]1;%s\a' "sl-ios - issue-"` +- Done: `printf '\e]1;%s\a' "sl-ios - reported to max"` +- Idle: `printf '\e]1;%s\a' "sl-ios - idle"` + +## Reference +- Issue #709: https://gitea.vayrette.com/seb/saltylab-firmware/issues/709 +- UWB firmware branch: `salty/uwb-tag-display-wireless` +- SaltyBot architecture: see saltylab-firmware repo docs diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj index c3a2c72..1a6ff66 100644 --- a/SulTee/SulTee.xcodeproj/project.pbxproj +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ A100000100000000000004AA /* WebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000004AB /* WebSocketClient.swift */; }; A100000100000000000005AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000100000000000005AB /* Assets.xcassets */; }; A100000100000000000009AA /* MQTTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000009AB /* MQTTClient.swift */; }; + A10000010000000000000AAA /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000AAB /* MapContentView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -24,6 +25,7 @@ A100000100000000000006AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A100000100000000000007AB /* SulTee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SulTee.app; sourceTree = BUILT_PRODUCTS_DIR; }; A100000100000000000009AB /* MQTTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTClient.swift; sourceTree = ""; }; + A10000010000000000000AAB /* MapContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapContentView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -53,6 +55,7 @@ A100000100000000000003AB /* SensorManager.swift */, A100000100000000000004AB /* WebSocketClient.swift */, A100000100000000000009AB /* MQTTClient.swift */, + A10000010000000000000AAB /* MapContentView.swift */, A100000100000000000005AB /* Assets.xcassets */, A100000100000000000006AB /* Info.plist */, ); @@ -142,6 +145,7 @@ A100000100000000000003AA /* SensorManager.swift in Sources */, A100000100000000000004AA /* WebSocketClient.swift in Sources */, A100000100000000000009AA /* MQTTClient.swift in Sources */, + A10000010000000000000AAA /* MapContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SulTee/SulTee.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SulTee/SulTee.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SulTee/SulTee.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SulTee/SulTee/ContentView.swift b/SulTee/SulTee/ContentView.swift index c9b1ad0..6ca50da 100644 --- a/SulTee/SulTee/ContentView.swift +++ b/SulTee/SulTee/ContentView.swift @@ -1,7 +1,24 @@ 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") } + } + } +} + +// 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 @@ -14,21 +31,33 @@ struct ContentView: View { Divider() sensorRatesGrid Divider() - if let dist = sensor.botDistanceMeters { - Text("Bot distance: \(dist, specifier: "%.1f") m") - .font(.title2) - .bold() + if let dist = sensor.distanceToRobot { + distanceRow(dist) } Spacer() followMeButton } .padding() - .navigationTitle("Sul-Tee") + .navigationTitle("SAUL-T-MOTE") .onAppear { editingURL = orinURL } } } - // MARK: - Subviews + // 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) { @@ -59,32 +88,22 @@ struct ContentView: View { sensor.updateURL(editingURL) } - 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: Sensor rates grid private var sensorRatesGrid: some View { Grid(horizontalSpacing: 20, verticalSpacing: 12) { GridRow { - rateCell(icon: "location.fill", label: "GPS", rate: sensor.gpsRate, unit: "Hz") - rateCell(icon: "gyroscope", label: "IMU", rate: sensor.imuRate, unit: "Hz") + 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, unit: "Hz") - rateCell(icon: "barometer", label: "Baro", rate: sensor.baroRate, unit: "Hz") + 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, unit: String) -> some View { + private func rateCell(icon: String, label: String, rate: Double) -> some View { VStack(spacing: 4) { Image(systemName: icon) .font(.title2) @@ -92,7 +111,7 @@ struct ContentView: View { Text(label) .font(.caption) .foregroundStyle(.secondary) - Text("\(Int(rate)) \(unit)") + Text("\(Int(rate)) Hz") .font(.title3.monospacedDigit()) .bold() } @@ -101,13 +120,23 @@ struct ContentView: View { .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) } + // MARK: Distance row + + private func distanceRow(_ dist: Double) -> some View { + HStack { + Image(systemName: "arrow.left.and.right") + Text(dist < 1000 + ? "Robot \(Int(dist)) m away" + : String(format: "Robot %.1f km away", dist / 1000)) + .font(.title2).bold() + } + } + + // MARK: Follow-Me button + private var followMeButton: some View { Button { - if sensor.isStreaming { - sensor.stopStreaming() - } else { - sensor.startStreaming() - } + sensor.isStreaming ? sensor.stopStreaming() : sensor.startStreaming() } label: { Label( sensor.isStreaming ? "Stop Follow-Me" : "Start Follow-Me", @@ -124,7 +153,7 @@ struct ContentView: View { .padding(.bottom, 8) } - // MARK: - Helpers + // MARK: Helpers private var wsColor: Color { switch sensor.wsState { diff --git a/SulTee/SulTee/Info.plist b/SulTee/SulTee/Info.plist index 8e3361b..d00c04b 100644 --- a/SulTee/SulTee/Info.plist +++ b/SulTee/SulTee/Info.plist @@ -10,6 +10,8 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleDisplayName + SAUL-T-MOTE CFBundleName $(PRODUCT_NAME) CFBundlePackageType diff --git a/SulTee/SulTee/MQTTClient.swift b/SulTee/SulTee/MQTTClient.swift index 88df9bc..8072714 100644 --- a/SulTee/SulTee/MQTTClient.swift +++ b/SulTee/SulTee/MQTTClient.swift @@ -1,8 +1,8 @@ import Foundation import Network -/// Minimal MQTT 3.1.1 client — CONNECT + PUBLISH (QoS 0) + PINGREQ only. -/// Sufficient for 1 Hz telemetry publishing; no subscription support needed. +/// Minimal MQTT 3.1.1 client — CONNECT + PUBLISH (QoS 0) + SUBSCRIBE (QoS 0) + PINGREQ. +/// Supports both publish and subscribe; no QoS 1/2 needed for this use-case. final class MQTTClient { struct Config { @@ -17,10 +17,16 @@ final class MQTTClient { enum State { case disconnected, connecting, connected } private(set) var state: State = .disconnected + + /// Called on the main queue for every received PUBLISH message: (topic, payload). + var onMessage: ((String, String) -> Void)? + private var config: Config private var connection: NWConnection? private var pingTimer: DispatchSourceTimer? private var shouldRun = false + private var subscriptions: [String] = [] // persisted across reconnects + private var nextPacketID: UInt16 = 1 private let queue = DispatchQueue(label: "mqtt.client", qos: .utility) init(config: Config) { @@ -47,21 +53,27 @@ final class MQTTClient { /// Publish a UTF-8 string payload to `topic` at QoS 0. func publish(topic: String, payload: String) { guard state == .connected else { return } - let packet = buildPublish(topic: topic, payload: payload) - connection?.send(content: packet, completion: .idempotent) + connection?.send(content: buildPublish(topic: topic, payload: payload), + completion: .idempotent) + } + + /// Subscribe to `topic` at QoS 0. Stored and re-sent automatically on reconnect. + func subscribe(topic: String) { + if !subscriptions.contains(topic) { subscriptions.append(topic) } + guard state == .connected else { return } + sendSubscribe(topic: topic) } // MARK: - Connection lifecycle private func openConnection() { state = .connecting - let host = NWEndpoint.Host(config.host) - let port = NWEndpoint.Port(rawValue: config.port)! - connection = NWConnection(host: host, port: port, using: .tcp) - connection?.stateUpdateHandler = { [weak self] newState in - self?.handleStateChange(newState) - } - connection?.start(queue: queue) + let conn = NWConnection(host: NWEndpoint.Host(config.host), + port: NWEndpoint.Port(rawValue: config.port)!, + using: .tcp) + conn.stateUpdateHandler = { [weak self] s in self?.handleStateChange(s) } + conn.start(queue: queue) + connection = conn scheduleRead() } @@ -82,32 +94,69 @@ final class MQTTClient { private func reconnectIfNeeded() { guard shouldRun else { return } - queue.asyncAfter(deadline: .now() + 3) { [weak self] in - self?.openConnection() - } + queue.asyncAfter(deadline: .now() + 3) { [weak self] in self?.openConnection() } } - // MARK: - Read loop (to receive CONNACK / PINGRESP) + // MARK: - Read loop private func scheduleRead() { - connection?.receive(minimumIncompleteLength: 2, maximumLength: 256) { [weak self] data, _, _, error in + connection?.receive(minimumIncompleteLength: 2, maximumLength: 4096) { [weak self] data, _, _, error in guard let self else { return } - if let data, !data.isEmpty { - self.handleIncoming(data) - } + if let data, !data.isEmpty { self.handleIncoming(data) } if error == nil { self.scheduleRead() } } } + /// Parse one or more MQTT packets from `data`. private func handleIncoming(_ data: Data) { - guard let first = data.first else { return } - switch first { - case 0x20: // CONNACK - state = .connected - case 0xD0: // PINGRESP — no action needed - break - default: - break + var i = data.startIndex + + while i < data.endIndex { + let firstByte = data[i] + i = data.index(after: i) + let packetType = firstByte & 0xF0 + + // Decode variable-length remaining-length field + var multiplier = 1 + var remaining = 0 + var lenByte: UInt8 = 0 + repeat { + guard i < data.endIndex else { return } + lenByte = data[i] + i = data.index(after: i) + remaining += Int(lenByte & 0x7F) * multiplier + multiplier *= 128 + } while lenByte & 0x80 != 0 + + guard let payloadEnd = data.index(i, offsetBy: remaining, limitedBy: data.endIndex) else { break } + + switch packetType { + case 0x20: // CONNACK + state = .connected + for topic in subscriptions { sendSubscribe(topic: topic) } + + case 0x30: // PUBLISH (QoS 0 — no packet identifier) + var j = i + if data.distance(from: j, to: payloadEnd) >= 2 { + let topicLen = Int(data[j]) << 8 | Int(data[data.index(after: j)]) + j = data.index(j, offsetBy: 2) + if let topicEnd = data.index(j, offsetBy: topicLen, limitedBy: payloadEnd) { + let topic = String(bytes: data[j.. Data { var payload = Data() - payload += mqttString("MQTT") // protocol name - payload.append(0x04) // protocol level (3.1.1) - payload.append(0xC2) // flags: username + password + clean session - payload += uint16BE(config.keepAlive) // keep-alive - payload += mqttString(config.clientID) // client ID - payload += mqttString(config.username) // username - payload += mqttString(config.password) // password + payload += mqttString("MQTT") + payload.append(0x04) // protocol level 3.1.1 + payload.append(0xC2) // flags: username + password + clean session + payload += uint16BE(config.keepAlive) + payload += mqttString(config.clientID) + payload += mqttString(config.username) + payload += mqttString(config.password) return mqttPacket(type: 0x10, payload: payload) } @@ -155,6 +198,16 @@ final class MQTTClient { return mqttPacket(type: 0x30, payload: body) } + private func sendSubscribe(topic: String) { + var payload = Data() + payload += uint16BE(nextPacketID) + nextPacketID &+= 1 + payload += mqttString(topic) + payload.append(0x00) // QoS 0 + connection?.send(content: mqttPacket(type: 0x82, payload: payload), + completion: .idempotent) + } + // MARK: - Encoding helpers private func mqttPacket(type: UInt8, payload: Data) -> Data { diff --git a/SulTee/SulTee/MapContentView.swift b/SulTee/SulTee/MapContentView.swift new file mode 100644 index 0000000..c44dc15 --- /dev/null +++ b/SulTee/SulTee/MapContentView.swift @@ -0,0 +1,184 @@ +import SwiftUI +import MapKit + +/// Full-screen map showing user (blue) and robot (orange) positions, +/// a follow-path line between them, and fading breadcrumb trails for both. +struct MapContentView: View { + @EnvironmentObject var sensor: SensorManager + + @State private var position: MapCameraPosition = .automatic + @State private var followUser = true + + var body: some View { + ZStack(alignment: .bottom) { + map + overlay + } + .ignoresSafeArea(edges: .top) + .onChange(of: sensor.userLocation) { _, coord in + if followUser, let coord { + withAnimation(.easeInOut(duration: 0.4)) { + position = .camera(MapCamera( + centerCoordinate: coord, + distance: 400, + heading: 0, + pitch: 0 + )) + } + } + } + } + + // MARK: - Map + + private var map: some View { + Map(position: $position) { + + // ── User breadcrumb trail (fading blue dots) + let userCrumbs = sensor.userBreadcrumbs + ForEach(userCrumbs.indices, id: \.self) { idx in + let opacity = Double(idx + 1) / Double(max(userCrumbs.count, 1)) + Annotation("", coordinate: userCrumbs[idx]) { + Circle() + .fill(.blue.opacity(opacity * 0.6)) + .frame(width: 7, height: 7) + } + .annotationTitles(.hidden) + } + + // ── Robot breadcrumb trail (fading orange dots) + let robotCrumbs = sensor.robotBreadcrumbs + ForEach(robotCrumbs.indices, id: \.self) { idx in + let opacity = Double(idx + 1) / Double(max(robotCrumbs.count, 1)) + Annotation("", coordinate: robotCrumbs[idx]) { + Circle() + .fill(.orange.opacity(opacity * 0.6)) + .frame(width: 7, height: 7) + } + .annotationTitles(.hidden) + } + + // ── Follow path line: robot → user + if let userLoc = sensor.userLocation, + let robotLoc = sensor.robotLocation { + MapPolyline(coordinates: [robotLoc, userLoc]) + .stroke(.yellow, style: StrokeStyle(lineWidth: 2, dash: [6, 4])) + } + + // ── User marker (blue) + if let userLoc = sensor.userLocation { + Annotation("You", coordinate: userLoc) { + ZStack { + Circle() + .fill(.blue.opacity(0.25)) + .frame(width: 36, height: 36) + Circle() + .fill(.blue) + .frame(width: 16, height: 16) + Circle() + .stroke(.white, lineWidth: 2.5) + .frame(width: 16, height: 16) + } + } + } + + // ── Robot marker (orange) + if let robotLoc = sensor.robotLocation { + Annotation("Robot", coordinate: robotLoc) { + ZStack { + Circle() + .fill(.orange.opacity(0.25)) + .frame(width: 36, height: 36) + Image(systemName: "car.fill") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + .padding(6) + .background(.orange, in: Circle()) + } + } + } + } + .mapStyle(.standard(elevation: .realistic)) + .onMapCameraChange { _ in + // User dragged map → stop auto-follow + followUser = false + } + .overlay(alignment: .topTrailing) { + followButton + } + } + + // MARK: - Re-centre button + + private var followButton: some View { + Button { + followUser = true + if let coord = sensor.userLocation { + withAnimation { + position = .camera(MapCamera( + centerCoordinate: coord, + distance: 400, + heading: 0, + pitch: 0 + )) + } + } + } label: { + Image(systemName: followUser ? "location.fill" : "location") + .padding(10) + .background(.ultraThinMaterial, in: Circle()) + } + .padding([.top, .trailing], 16) + .padding(.top, 44) // below nav bar + } + + // MARK: - Stats overlay + + private var overlay: some View { + HStack(spacing: 20) { + if let dist = sensor.distanceToRobot { + statCell(value: distanceString(dist), + label: "distance", + icon: "arrow.left.and.right") + } + if sensor.robotSpeed > 0.2 { + statCell(value: String(format: "%.1f m/s", sensor.robotSpeed), + label: "robot spd", + icon: "speedometer") + } + if !sensor.isStreaming { + Text("Start Follow-Me to stream") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .padding(.bottom, 24) + .padding(.horizontal, 16) + } + + private func statCell(value: String, label: String, icon: String) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .font(.caption) + VStack(alignment: .leading, spacing: 0) { + Text(value).font(.headline.monospacedDigit()) + Text(label).font(.caption2).foregroundStyle(.secondary) + } + } + } + + private func distanceString(_ metres: Double) -> String { + metres < 1000 + ? "\(Int(metres)) m" + : String(format: "%.1f km", metres / 1000) + } +} + +#Preview { + MapContentView() + .environmentObject(SensorManager()) +} diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 9604f0a..41db57e 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -1,30 +1,47 @@ import Foundation import CoreLocation import CoreMotion +import MapKit import Combine -/// Manages all iPhone sensors and forwards data to the WebSocket client -/// and MQTT broker (topic: saltybot/ios/gps, 1 Hz). +/// Manages all iPhone sensors, publishes iOS GPS to MQTT, subscribes to robot GPS, +/// and exposes state for the map and status views. final class SensorManager: NSObject, ObservableObject { - // MARK: - Published state for UI + // MARK: - Streaming state @Published var isStreaming = false @Published var wsState: WebSocketClient.ConnectionState = .disconnected - @Published var gpsRate: Double = 0 - @Published var imuRate: Double = 0 - @Published var headingRate: Double = 0 - @Published var baroRate: Double = 0 - @Published var botDistanceMeters: Double? = nil - // MARK: - WebSocket + // MARK: - Sensor rates (Hz) + + @Published var gpsRate: Double = 0 + @Published var imuRate: Double = 0 + @Published var headingRate: Double = 0 + @Published var baroRate: Double = 0 + + // MARK: - User (phone) position + + @Published var userLocation: CLLocationCoordinate2D? = nil + @Published var userBreadcrumbs: [CLLocationCoordinate2D] = [] + + // MARK: - Robot position (from MQTT saltybot/phone/gps) + + @Published var robotLocation: CLLocationCoordinate2D? = nil + @Published var robotBreadcrumbs: [CLLocationCoordinate2D] = [] + @Published var robotSpeed: Double = 0 + + // MARK: - Derived + + @Published var distanceToRobot: Double? = nil + + // MARK: - WebSocket config (sensor stream to Orin) static let defaultOrinURL = "ws://100.64.0.2:9090" private static let orinURLKey = "orinURL" private(set) var ws: WebSocketClient - /// Current Orin WebSocket URL string (persisted in UserDefaults). var orinURLString: String { get { UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL } set { UserDefaults.standard.set(newValue, forKey: Self.orinURLKey) } @@ -33,29 +50,32 @@ final class SensorManager: NSObject, ObservableObject { // MARK: - MQTT private let mqtt = MQTTClient(config: .init( - host: "192.168.87.29", - port: 1883, + host: "192.168.87.29", + port: 1883, username: "mqtt_seb", password: "mqtt_pass", - clientID: "sultee-ios-\(UUID().uuidString.prefix(8))" + clientID: "saul-t-mote-\(UUID().uuidString.prefix(8))" )) - private static let mqttGPSTopic = "saltybot/ios/gps" + private static let iosGPSTopic = "saltybot/ios/gps" + private static let robotGPSTopic = "saltybot/phone/gps" + private static let maxBreadcrumbs = 60 + private var lastKnownLocation: CLLocation? private var mqttPublishTimer: Timer? // MARK: - Sensors private let locationManager = CLLocationManager() - private let motionManager = CMMotionManager() - private let altimeter = CMAltimeter() - private var cancellables = Set() + private let motionManager = CMMotionManager() + private let altimeter = CMAltimeter() + private var cancellables = Set() - // MARK: - Rate counters (counts per second) + // MARK: - Rate counters - private var gpsCounts: [Date] = [] - private var imuCounts: [Date] = [] + private var gpsCounts: [Date] = [] + private var imuCounts: [Date] = [] private var headingCounts: [Date] = [] - private var baroCounts: [Date] = [] + private var baroCounts: [Date] = [] private var rateTimer: Timer? // MARK: - Init @@ -64,9 +84,9 @@ final class SensorManager: NSObject, ObservableObject { let urlString = UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL self.ws = WebSocketClient(url: URL(string: urlString) ?? URL(string: Self.defaultOrinURL)!) super.init() + locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation - // Use dual-frequency GPS (L1+L5) on iPhone 15 Pro — automatic when accuracy is set to Best locationManager.distanceFilter = kCLDistanceFilterNone locationManager.allowsBackgroundLocationUpdates = true locationManager.pausesLocationUpdatesAutomatically = false @@ -76,6 +96,11 @@ final class SensorManager: NSObject, ObservableObject { .receive(on: DispatchQueue.main) .assign(to: \.wsState, on: self) .store(in: &cancellables) + + mqtt.onMessage = { [weak self] topic, payload in + guard let self, topic == Self.robotGPSTopic else { return } + self.handleRobotGPS(payload) + } } // MARK: - Public control @@ -85,6 +110,7 @@ final class SensorManager: NSObject, ObservableObject { isStreaming = true ws.connect() mqtt.connect() + mqtt.subscribe(topic: Self.robotGPSTopic) requestPermissionsAndStartSensors() startRateTimer() startMQTTPublishTimer() @@ -96,14 +122,10 @@ final class SensorManager: NSObject, ObservableObject { ws.disconnect() mqtt.disconnect() stopSensors() - rateTimer?.invalidate() - rateTimer = nil - mqttPublishTimer?.invalidate() - mqttPublishTimer = nil + rateTimer?.invalidate(); rateTimer = nil + mqttPublishTimer?.invalidate(); mqttPublishTimer = nil } - /// Call when the user edits the Orin URL. Persists the value and updates - /// the client URL; takes effect on the next connect(). func updateURL(_ urlString: String) { guard !isStreaming else { return } orinURLString = urlString @@ -134,7 +156,37 @@ final class SensorManager: NSObject, ObservableObject { ] guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { return } - mqtt.publish(topic: Self.mqttGPSTopic, payload: json) + mqtt.publish(topic: Self.iosGPSTopic, payload: json) + } + + // MARK: - Robot GPS subscription handler + + private func handleRobotGPS(_ payload: String) { + guard let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let lat = json["lat"] as? Double, + let lon = json["lon"] as? Double else { return } + + let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon) + robotLocation = coord + robotSpeed = (json["speed_ms"] as? Double) ?? 0 + appendBreadcrumb(coord, to: &robotBreadcrumbs) + updateDistance() + } + + // MARK: - Breadcrumbs + distance + + private func appendBreadcrumb(_ coord: CLLocationCoordinate2D, + to list: inout [CLLocationCoordinate2D]) { + list.append(coord) + if list.count > Self.maxBreadcrumbs { list.removeFirst() } + } + + private func updateDistance() { + guard let user = userLocation, let robot = robotLocation else { return } + let a = CLLocation(latitude: user.latitude, longitude: user.longitude) + let b = CLLocation(latitude: robot.latitude, longitude: robot.longitude) + distanceToRobot = a.distance(from: b) } // MARK: - Sensor start / stop @@ -157,34 +209,17 @@ final class SensorManager: NSObject, ObservableObject { private func startIMU() { guard motionManager.isDeviceMotionAvailable else { return } motionManager.deviceMotionUpdateInterval = 1.0 / 100.0 // 100 Hz - motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in + motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, _ in guard let self, let motion else { return } self.recordEvent(in: &self.imuCounts) - let ts = Date().timeIntervalSince1970 self.ws.send([ "type": "imu", - "timestamp": ts, + "timestamp": Date().timeIntervalSince1970, "data": [ - "accel": [ - "x": motion.userAcceleration.x, - "y": motion.userAcceleration.y, - "z": motion.userAcceleration.z - ], - "gyro": [ - "x": motion.rotationRate.x, - "y": motion.rotationRate.y, - "z": motion.rotationRate.z - ], - "attitude": [ - "roll": motion.attitude.roll, - "pitch": motion.attitude.pitch, - "yaw": motion.attitude.yaw - ], - "gravity": [ - "x": motion.gravity.x, - "y": motion.gravity.y, - "z": motion.gravity.z - ], + "accel": ["x": motion.userAcceleration.x, "y": motion.userAcceleration.y, "z": motion.userAcceleration.z], + "gyro": ["x": motion.rotationRate.x, "y": motion.rotationRate.y, "z": motion.rotationRate.z], + "attitude": ["roll": motion.attitude.roll, "pitch": motion.attitude.pitch, "yaw": motion.attitude.yaw], + "gravity": ["x": motion.gravity.x, "y": motion.gravity.y, "z": motion.gravity.z], "magneticField": [ "x": motion.magneticField.field.x, "y": motion.magneticField.field.y, @@ -198,16 +233,14 @@ final class SensorManager: NSObject, ObservableObject { private func startBarometer() { guard CMAltimeter.isRelativeAltitudeAvailable() else { return } - altimeter.startRelativeAltitudeUpdates(to: .main) { [weak self] data, error in + altimeter.startRelativeAltitudeUpdates(to: .main) { [weak self] data, _ in guard let self, let data else { return } self.recordEvent(in: &self.baroCounts) self.ws.send([ "type": "baro", "timestamp": Date().timeIntervalSince1970, - "data": [ - "relativeAltitude": data.relativeAltitude.doubleValue, - "pressure": data.pressure.doubleValue - ] + "data": ["relativeAltitude": data.relativeAltitude.doubleValue, + "pressure": data.pressure.doubleValue] ]) } } @@ -220,22 +253,19 @@ final class SensorManager: NSObject, ObservableObject { } } - private func recordEvent(in list: inout [Date]) { - list.append(Date()) - } + private func recordEvent(in list: inout [Date]) { list.append(Date()) } private func updateRates() { let cutoff = Date().addingTimeInterval(-1.0) - gpsCounts = gpsCounts.filter { $0 > cutoff } - imuCounts = imuCounts.filter { $0 > cutoff } + gpsCounts = gpsCounts.filter { $0 > cutoff } + imuCounts = imuCounts.filter { $0 > cutoff } headingCounts = headingCounts.filter { $0 > cutoff } - baroCounts = baroCounts.filter { $0 > cutoff } - + baroCounts = baroCounts.filter { $0 > cutoff } DispatchQueue.main.async { - self.gpsRate = Double(self.gpsCounts.count) - self.imuRate = Double(self.imuCounts.count) + self.gpsRate = Double(self.gpsCounts.count) + self.imuRate = Double(self.imuCounts.count) self.headingRate = Double(self.headingCounts.count) - self.baroRate = Double(self.baroCounts.count) + self.baroRate = Double(self.baroCounts.count) } } } @@ -247,20 +277,25 @@ extension SensorManager: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } lastKnownLocation = loc + let coord = loc.coordinate + userLocation = coord + appendBreadcrumb(coord, to: &userBreadcrumbs) + updateDistance() recordEvent(in: &gpsCounts) + ws.send([ "type": "gps", "timestamp": loc.timestamp.timeIntervalSince1970, "data": [ - "latitude": loc.coordinate.latitude, - "longitude": loc.coordinate.longitude, - "altitude": loc.altitude, - "horizontalAccuracy": loc.horizontalAccuracy, - "verticalAccuracy": loc.verticalAccuracy, - "speed": loc.speed, - "speedAccuracy": loc.speedAccuracy, - "course": loc.course, - "courseAccuracy": loc.courseAccuracy + "latitude": loc.coordinate.latitude, + "longitude": loc.coordinate.longitude, + "altitude": loc.altitude, + "horizontalAccuracy":loc.horizontalAccuracy, + "verticalAccuracy": loc.verticalAccuracy, + "speed": loc.speed, + "speedAccuracy": loc.speedAccuracy, + "course": loc.course, + "courseAccuracy": loc.courseAccuracy ] ]) } @@ -272,11 +307,9 @@ extension SensorManager: CLLocationManagerDelegate { "timestamp": Date().timeIntervalSince1970, "data": [ "magneticHeading": newHeading.magneticHeading, - "trueHeading": newHeading.trueHeading, + "trueHeading": newHeading.trueHeading, "headingAccuracy": newHeading.headingAccuracy, - "x": newHeading.x, - "y": newHeading.y, - "z": newHeading.z + "x": newHeading.x, "y": newHeading.y, "z": newHeading.z ] ]) } @@ -292,8 +325,7 @@ extension SensorManager: CLLocationManagerDelegate { manager.startUpdatingLocation() manager.startUpdatingHeading() } - default: - break + default: break } } } -- 2.47.2 From 4eb2fce08f1768cac7251df100a30a05b23b886f Mon Sep 17 00:00:00 2001 From: sl-ios Date: Sat, 4 Apr 2026 12:12:21 -0400 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20Add=20UWB=20integration=20=E2=80=94?= =?UTF-8?q?=20follow=20mode,=20range=20presets,=20UWB=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UWBModels.swift (new): - FollowMode enum: .gps / .uwb — publishes {"mode":"gps|uwb"} to saltybot/follow/mode - FollowPreset enum: .close(1.5m) / .medium(3m) / .far(5m) — publishes {"range_m":N,"preset":"..."} to saltybot/follow/range - UWBPosition struct: x/y/z + timestamp - UWBRange struct: anchorID + rangeMetres + timestamp SensorManager: - Subscribes to saltybot/uwb/range + saltybot/uwb/position on startStreaming - handleUWBRange: updates uwbRanges[anchorID] (keyed dict) - handleUWBPosition: updates uwbPosition + sets uwbActive=true - UWB staleness watchdog (1Hz timer): clears uwbActive and prunes stale ranges >3s - setFollowMode(_:) / setFollowPreset(_:): update state + publish to MQTT immediately - Publishes current follow mode+range on connect MapContentView: - UWB status badge (top-left): green/gray dot, "UWB Active|Out of Range", per-anchor range readouts (e.g. A1 2.34m) - Follow mode segmented control: GPS | UWB - Follow range segmented control: Close | Medium | Far (shows metres) - MapCircle follow-range ring around robot: green inside, orange outside range - Stats bar: distance turns green + checkmark when user is inside follow range; UWB (x,y) coord shown when UWB mode active and position known Co-Authored-By: Claude Sonnet 4.6 --- SulTee/SulTee.xcodeproj/project.pbxproj | 4 + SulTee/SulTee/MapContentView.swift | 203 ++++++++++++++++++------ SulTee/SulTee/SensorManager.swift | 147 +++++++++++++---- SulTee/SulTee/UWBModels.swift | 51 ++++++ 4 files changed, 325 insertions(+), 80 deletions(-) create mode 100644 SulTee/SulTee/UWBModels.swift diff --git a/SulTee/SulTee.xcodeproj/project.pbxproj b/SulTee/SulTee.xcodeproj/project.pbxproj index 1a6ff66..a65c916 100644 --- a/SulTee/SulTee.xcodeproj/project.pbxproj +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ A100000100000000000005AA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A100000100000000000005AB /* Assets.xcassets */; }; A100000100000000000009AA /* MQTTClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A100000100000000000009AB /* MQTTClient.swift */; }; A10000010000000000000AAA /* MapContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000AAB /* MapContentView.swift */; }; + A10000010000000000000BAA /* UWBModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000010000000000000BAB /* UWBModels.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -26,6 +27,7 @@ A100000100000000000007AB /* SulTee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SulTee.app; sourceTree = BUILT_PRODUCTS_DIR; }; A100000100000000000009AB /* MQTTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTClient.swift; sourceTree = ""; }; A10000010000000000000AAB /* MapContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapContentView.swift; sourceTree = ""; }; + A10000010000000000000BAB /* UWBModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UWBModels.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,6 +58,7 @@ A100000100000000000004AB /* WebSocketClient.swift */, A100000100000000000009AB /* MQTTClient.swift */, A10000010000000000000AAB /* MapContentView.swift */, + A10000010000000000000BAB /* UWBModels.swift */, A100000100000000000005AB /* Assets.xcassets */, A100000100000000000006AB /* Info.plist */, ); @@ -146,6 +149,7 @@ A100000100000000000004AA /* WebSocketClient.swift in Sources */, A100000100000000000009AA /* MQTTClient.swift in Sources */, A10000010000000000000AAA /* MapContentView.swift in Sources */, + A10000010000000000000BAA /* UWBModels.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SulTee/SulTee/MapContentView.swift b/SulTee/SulTee/MapContentView.swift index c44dc15..d317324 100644 --- a/SulTee/SulTee/MapContentView.swift +++ b/SulTee/SulTee/MapContentView.swift @@ -1,18 +1,33 @@ import SwiftUI import MapKit +extension CLLocationCoordinate2D: @retroactive Equatable { + public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { + lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude + } +} + /// Full-screen map showing user (blue) and robot (orange) positions, -/// a follow-path line between them, and fading breadcrumb trails for both. +/// a follow-path line, fading breadcrumb trails, UWB status, and follow controls. struct MapContentView: View { @EnvironmentObject var sensor: SensorManager - @State private var position: MapCameraPosition = .automatic - @State private var followUser = true + @State private var position: MapCameraPosition = .automatic + @State private var followUser = true + + private var insideFollowRange: Bool { + guard let d = sensor.distanceToRobot else { return false } + return d <= sensor.followPreset.metres + } var body: some View { ZStack(alignment: .bottom) { map - overlay + VStack(spacing: 0) { + Spacer() + followControls + statsBar + } } .ignoresSafeArea(edges: .top) .onChange(of: sensor.userLocation) { _, coord in @@ -20,20 +35,32 @@ struct MapContentView: View { withAnimation(.easeInOut(duration: 0.4)) { position = .camera(MapCamera( centerCoordinate: coord, - distance: 400, - heading: 0, - pitch: 0 + distance: 400, heading: 0, pitch: 0 )) } } } } - // MARK: - Map + // MARK: - Map content private var map: some View { Map(position: $position) { + // ── Follow range circle around robot + if let robotLoc = sensor.robotLocation { + MapCircle(center: robotLoc, radius: sensor.followPreset.metres) + .foregroundStyle( + insideFollowRange + ? Color.green.opacity(0.12) + : Color.orange.opacity(0.12) + ) + .stroke( + insideFollowRange ? Color.green : Color.orange, + style: StrokeStyle(lineWidth: 1.5) + ) + } + // ── User breadcrumb trail (fading blue dots) let userCrumbs = sensor.userBreadcrumbs ForEach(userCrumbs.indices, id: \.self) { idx in @@ -59,7 +86,7 @@ struct MapContentView: View { } // ── Follow path line: robot → user - if let userLoc = sensor.userLocation, + if let userLoc = sensor.userLocation, let robotLoc = sensor.robotLocation { MapPolyline(coordinates: [robotLoc, userLoc]) .stroke(.yellow, style: StrokeStyle(lineWidth: 2, dash: [6, 4])) @@ -69,15 +96,9 @@ struct MapContentView: View { if let userLoc = sensor.userLocation { Annotation("You", coordinate: userLoc) { ZStack { - Circle() - .fill(.blue.opacity(0.25)) - .frame(width: 36, height: 36) - Circle() - .fill(.blue) - .frame(width: 16, height: 16) - Circle() - .stroke(.white, lineWidth: 2.5) - .frame(width: 16, height: 16) + Circle().fill(.blue.opacity(0.25)).frame(width: 36, height: 36) + Circle().fill(.blue).frame(width: 16, height: 16) + Circle().stroke(.white, lineWidth: 2.5).frame(width: 16, height: 16) } } } @@ -86,9 +107,7 @@ struct MapContentView: View { if let robotLoc = sensor.robotLocation { Annotation("Robot", coordinate: robotLoc) { ZStack { - Circle() - .fill(.orange.opacity(0.25)) - .frame(width: 36, height: 36) + Circle().fill(.orange.opacity(0.25)).frame(width: 36, height: 36) Image(systemName: "car.fill") .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) @@ -99,27 +118,54 @@ struct MapContentView: View { } } .mapStyle(.standard(elevation: .realistic)) - .onMapCameraChange { _ in - // User dragged map → stop auto-follow - followUser = false - } - .overlay(alignment: .topTrailing) { - followButton - } + .onMapCameraChange { _ in followUser = false } + .overlay(alignment: .topTrailing) { recenterButton } + .overlay(alignment: .topLeading) { uwbBadge } } - // MARK: - Re-centre button + // MARK: - UWB status badge (top-left) - private var followButton: some View { + private var uwbBadge: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Circle() + .fill(sensor.uwbActive ? Color.green : Color.gray) + .frame(width: 9, height: 9) + .shadow(color: sensor.uwbActive ? .green : .clear, radius: 4) + Text(sensor.uwbActive ? "UWB Active" : "UWB Out of Range") + .font(.caption2.bold()) + .foregroundStyle(sensor.uwbActive ? .primary : .secondary) + } + + // Per-anchor ranges + let sortedRanges = sensor.uwbRanges.values.sorted { $0.anchorID < $1.anchorID } + if !sortedRanges.isEmpty { + HStack(spacing: 8) { + ForEach(sortedRanges) { r in + Text("\(r.anchorID) \(r.rangeMetres, specifier: "%.2f")m") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10)) + .padding(.top, 56) // below status bar + .padding(.leading, 16) + } + + // MARK: - Re-centre button (top-right) + + private var recenterButton: some View { Button { followUser = true if let coord = sensor.userLocation { withAnimation { position = .camera(MapCamera( centerCoordinate: coord, - distance: 400, - heading: 0, - pitch: 0 + distance: 400, heading: 0, pitch: 0 )) } } @@ -128,24 +174,84 @@ struct MapContentView: View { .padding(10) .background(.ultraThinMaterial, in: Circle()) } - .padding([.top, .trailing], 16) - .padding(.top, 44) // below nav bar + .padding(.top, 56) + .padding(.trailing, 16) } - // MARK: - Stats overlay + // MARK: - Follow controls panel - private var overlay: some View { + private var followControls: some View { + VStack(spacing: 10) { + + // Follow mode: GPS | UWB + VStack(alignment: .leading, spacing: 4) { + Label("Follow Mode", systemImage: "location.circle") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Picker("Follow Mode", selection: Binding( + get: { sensor.followMode }, + set: { sensor.setFollowMode($0) } + )) { + ForEach(FollowMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + } + + // Follow range: Close | Medium | Far + VStack(alignment: .leading, spacing: 4) { + HStack { + Label("Follow Range", systemImage: "circle.dashed") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Spacer() + Text("\(sensor.followPreset.metres, specifier: "%.1f") m") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Picker("Range", selection: Binding( + get: { sensor.followPreset }, + set: { sensor.setFollowPreset($0) } + )) { + ForEach(FollowPreset.allCases) { preset in + Text(preset.rawValue).tag(preset) + } + } + .pickerStyle(.segmented) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + // MARK: - Stats bar + + private var statsBar: some View { HStack(spacing: 20) { if let dist = sensor.distanceToRobot { - statCell(value: distanceString(dist), - label: "distance", - icon: "arrow.left.and.right") + statCell( + value: distanceString(dist), + label: "distance", + icon: insideFollowRange ? "checkmark.circle.fill" : "arrow.left.and.right", + tint: insideFollowRange ? .green : .primary + ) } if sensor.robotSpeed > 0.2 { statCell(value: String(format: "%.1f m/s", sensor.robotSpeed), label: "robot spd", icon: "speedometer") } + if sensor.followMode == .uwb, let pos = sensor.uwbPosition { + statCell( + value: String(format: "(%.1f, %.1f)", pos.x, pos.y), + label: "UWB pos", + icon: "waveform" + ) + } if !sensor.isStreaming { Text("Start Follow-Me to stream") .font(.caption) @@ -159,22 +265,25 @@ struct MapContentView: View { .padding(.horizontal, 16) } - private func statCell(value: String, label: String, icon: String) -> some View { + // MARK: - Helpers + + private func statCell(value: String, + label: String, + icon: String, + tint: Color = .primary) -> some View { HStack(spacing: 6) { Image(systemName: icon) - .foregroundStyle(.secondary) + .foregroundStyle(tint == .primary ? .secondary : tint) .font(.caption) VStack(alignment: .leading, spacing: 0) { - Text(value).font(.headline.monospacedDigit()) + Text(value).font(.headline.monospacedDigit()).foregroundStyle(tint) Text(label).font(.caption2).foregroundStyle(.secondary) } } } - private func distanceString(_ metres: Double) -> String { - metres < 1000 - ? "\(Int(metres)) m" - : String(format: "%.1f km", metres / 1000) + private func distanceString(_ m: Double) -> String { + m < 1000 ? "\(Int(m)) m" : String(format: "%.1f km", m / 1000) } } diff --git a/SulTee/SulTee/SensorManager.swift b/SulTee/SulTee/SensorManager.swift index 41db57e..34a9121 100644 --- a/SulTee/SulTee/SensorManager.swift +++ b/SulTee/SulTee/SensorManager.swift @@ -4,8 +4,8 @@ import CoreMotion import MapKit import Combine -/// Manages all iPhone sensors, publishes iOS GPS to MQTT, subscribes to robot GPS, -/// and exposes state for the map and status views. +/// Manages all iPhone sensors, publishes iOS GPS to MQTT, subscribes to robot GPS +/// and UWB data, and exposes state for the map and status views. final class SensorManager: NSObject, ObservableObject { // MARK: - Streaming state @@ -25,19 +25,27 @@ final class SensorManager: NSObject, ObservableObject { @Published var userLocation: CLLocationCoordinate2D? = nil @Published var userBreadcrumbs: [CLLocationCoordinate2D] = [] - // MARK: - Robot position (from MQTT saltybot/phone/gps) + // MARK: - Robot position (saltybot/phone/gps) @Published var robotLocation: CLLocationCoordinate2D? = nil @Published var robotBreadcrumbs: [CLLocationCoordinate2D] = [] @Published var robotSpeed: Double = 0 + @Published var distanceToRobot: Double? = nil - // MARK: - Derived + // MARK: - UWB (saltybot/uwb/range + saltybot/uwb/position) - @Published var distanceToRobot: Double? = nil + @Published var uwbPosition: UWBPosition? = nil + @Published var uwbRanges: [String: UWBRange] = [:] // anchorID → UWBRange + @Published var uwbActive: Bool = false - // MARK: - WebSocket config (sensor stream to Orin) + // MARK: - Follow settings - static let defaultOrinURL = "ws://100.64.0.2:9090" + @Published var followMode: FollowMode = .gps + @Published var followPreset: FollowPreset = .medium + + // MARK: - WebSocket config + + static let defaultOrinURL = "ws://100.64.0.2:9090" private static let orinURLKey = "orinURL" private(set) var ws: WebSocketClient @@ -56,12 +64,19 @@ final class SensorManager: NSObject, ObservableObject { password: "mqtt_pass", clientID: "saul-t-mote-\(UUID().uuidString.prefix(8))" )) - private static let iosGPSTopic = "saltybot/ios/gps" - private static let robotGPSTopic = "saltybot/phone/gps" - private static let maxBreadcrumbs = 60 - private var lastKnownLocation: CLLocation? - private var mqttPublishTimer: Timer? + private static let iosGPSTopic = "saltybot/ios/gps" + private static let robotGPSTopic = "saltybot/phone/gps" + private static let uwbRangeTopic = "saltybot/uwb/range" + private static let uwbPositionTopic = "saltybot/uwb/position" + private static let followModeTopic = "saltybot/follow/mode" + private static let followRangeTopic = "saltybot/follow/range" + private static let maxBreadcrumbs = 60 + private static let uwbStaleSeconds = 3.0 + + private var lastKnownLocation: CLLocation? + private var mqttPublishTimer: Timer? + private var uwbStalenessTimer: Timer? // MARK: - Sensors @@ -76,13 +91,13 @@ final class SensorManager: NSObject, ObservableObject { private var imuCounts: [Date] = [] private var headingCounts: [Date] = [] private var baroCounts: [Date] = [] - private var rateTimer: Timer? + private var rateTimer: Timer? // MARK: - Init override init() { - let urlString = UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL - self.ws = WebSocketClient(url: URL(string: urlString) ?? URL(string: Self.defaultOrinURL)!) + let urlStr = UserDefaults.standard.string(forKey: Self.orinURLKey) ?? Self.defaultOrinURL + self.ws = WebSocketClient(url: URL(string: urlStr) ?? URL(string: Self.defaultOrinURL)!) super.init() locationManager.delegate = self @@ -98,8 +113,13 @@ final class SensorManager: NSObject, ObservableObject { .store(in: &cancellables) mqtt.onMessage = { [weak self] topic, payload in - guard let self, topic == Self.robotGPSTopic else { return } - self.handleRobotGPS(payload) + guard let self else { return } + switch topic { + case Self.robotGPSTopic: self.handleRobotGPS(payload) + case Self.uwbRangeTopic: self.handleUWBRange(payload) + case Self.uwbPositionTopic: self.handleUWBPosition(payload) + default: break + } } } @@ -111,9 +131,15 @@ final class SensorManager: NSObject, ObservableObject { ws.connect() mqtt.connect() mqtt.subscribe(topic: Self.robotGPSTopic) + mqtt.subscribe(topic: Self.uwbRangeTopic) + mqtt.subscribe(topic: Self.uwbPositionTopic) requestPermissionsAndStartSensors() startRateTimer() startMQTTPublishTimer() + startUWBStalenessTimer() + // Publish current follow settings immediately on connect + publishFollowMode() + publishFollowPreset() } func stopStreaming() { @@ -122,8 +148,9 @@ final class SensorManager: NSObject, ObservableObject { ws.disconnect() mqtt.disconnect() stopSensors() - rateTimer?.invalidate(); rateTimer = nil - mqttPublishTimer?.invalidate(); mqttPublishTimer = nil + rateTimer?.invalidate(); rateTimer = nil + mqttPublishTimer?.invalidate(); mqttPublishTimer = nil + uwbStalenessTimer?.invalidate(); uwbStalenessTimer = nil } func updateURL(_ urlString: String) { @@ -134,6 +161,28 @@ final class SensorManager: NSObject, ObservableObject { } } + /// Change follow mode and publish to MQTT immediately. + func setFollowMode(_ mode: FollowMode) { + followMode = mode + publishFollowMode() + } + + /// Change follow range preset and publish to MQTT immediately. + func setFollowPreset(_ preset: FollowPreset) { + followPreset = preset + publishFollowPreset() + } + + // MARK: - MQTT publish helpers + + private func publishFollowMode() { + mqtt.publish(topic: Self.followModeTopic, payload: followMode.mqttPayload) + } + + private func publishFollowPreset() { + mqtt.publish(topic: Self.followRangeTopic, payload: followPreset.mqttPayload) + } + // MARK: - MQTT GPS publish (1 Hz) private func startMQTTPublishTimer() { @@ -159,14 +208,13 @@ final class SensorManager: NSObject, ObservableObject { mqtt.publish(topic: Self.iosGPSTopic, payload: json) } - // MARK: - Robot GPS subscription handler + // MARK: - Incoming MQTT handlers private func handleRobotGPS(_ payload: String) { guard let data = payload.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let lat = json["lat"] as? Double, - let lon = json["lon"] as? Double else { return } - + let lat = json["lat"] as? Double, + let lon = json["lon"] as? Double else { return } let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon) robotLocation = coord robotSpeed = (json["speed_ms"] as? Double) ?? 0 @@ -174,6 +222,39 @@ final class SensorManager: NSObject, ObservableObject { updateDistance() } + private func handleUWBRange(_ payload: String) { + guard let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let anchorID = json["anchor_id"] as? String, + let rangeM = json["range_m"] as? Double else { return } + uwbRanges[anchorID] = UWBRange(anchorID: anchorID, + rangeMetres: rangeM, + timestamp: Date()) + } + + private func handleUWBPosition(_ payload: String) { + guard let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let x = json["x"] as? Double, + let y = json["y"] as? Double, + let z = json["z"] as? Double else { return } + uwbPosition = UWBPosition(x: x, y: y, z: z, timestamp: Date()) + uwbActive = true + } + + // MARK: - UWB staleness watchdog + + private func startUWBStalenessTimer() { + uwbStalenessTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + let cutoff = Date().addingTimeInterval(-Self.uwbStaleSeconds) + if let pos = self.uwbPosition, pos.timestamp < cutoff { + self.uwbActive = false + } + self.uwbRanges = self.uwbRanges.filter { $0.value.timestamp > cutoff } + } + } + // MARK: - Breadcrumbs + distance private func appendBreadcrumb(_ coord: CLLocationCoordinate2D, @@ -208,7 +289,7 @@ final class SensorManager: NSObject, ObservableObject { private func startIMU() { guard motionManager.isDeviceMotionAvailable else { return } - motionManager.deviceMotionUpdateInterval = 1.0 / 100.0 // 100 Hz + motionManager.deviceMotionUpdateInterval = 1.0 / 100.0 motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, _ in guard let self, let motion else { return } self.recordEvent(in: &self.imuCounts) @@ -287,15 +368,15 @@ extension SensorManager: CLLocationManagerDelegate { "type": "gps", "timestamp": loc.timestamp.timeIntervalSince1970, "data": [ - "latitude": loc.coordinate.latitude, - "longitude": loc.coordinate.longitude, - "altitude": loc.altitude, - "horizontalAccuracy":loc.horizontalAccuracy, - "verticalAccuracy": loc.verticalAccuracy, - "speed": loc.speed, - "speedAccuracy": loc.speedAccuracy, - "course": loc.course, - "courseAccuracy": loc.courseAccuracy + "latitude": loc.coordinate.latitude, + "longitude": loc.coordinate.longitude, + "altitude": loc.altitude, + "horizontalAccuracy": loc.horizontalAccuracy, + "verticalAccuracy": loc.verticalAccuracy, + "speed": loc.speed, + "speedAccuracy": loc.speedAccuracy, + "course": loc.course, + "courseAccuracy": loc.courseAccuracy ] ]) } diff --git a/SulTee/SulTee/UWBModels.swift b/SulTee/SulTee/UWBModels.swift new file mode 100644 index 0000000..90efff3 --- /dev/null +++ b/SulTee/SulTee/UWBModels.swift @@ -0,0 +1,51 @@ +import Foundation + +// MARK: - Follow mode + +enum FollowMode: String, CaseIterable, Identifiable { + case gps = "GPS" + case uwb = "UWB" + var id: String { rawValue } + + var mqttPayload: String { + "{\"mode\":\"\(rawValue.lowercased())\"}" + } +} + +// MARK: - Follow range preset + +enum FollowPreset: String, CaseIterable, Identifiable { + case close = "Close" + case medium = "Medium" + case far = "Far" + var id: String { rawValue } + + var metres: Double { + switch self { + case .close: return 1.5 + case .medium: return 3.0 + case .far: return 5.0 + } + } + + var mqttPayload: String { + let d: [String: Any] = ["range_m": metres, "preset": rawValue.lowercased()] + guard let data = try? JSONSerialization.data(withJSONObject: d), + let str = String(data: data, encoding: .utf8) else { return "{}" } + return str + } +} + +// MARK: - UWB data types + +struct UWBPosition { + let x, y, z: Double + let timestamp: Date +} + +struct UWBRange: Identifiable { + let anchorID: String + let rangeMetres: Double + let timestamp: Date + var id: String { anchorID } +} -- 2.47.2