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 new file mode 100644 index 0000000..1a6ff66 --- /dev/null +++ b/SulTee/SulTee.xcodeproj/project.pbxproj @@ -0,0 +1,349 @@ +// !$*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 */; }; + 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 */ + 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; }; + 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 */ + 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 */, + A100000100000000000009AB /* MQTTClient.swift */, + A10000010000000000000AAB /* MapContentView.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 */, + A100000100000000000009AA /* MQTTClient.swift in Sources */, + A10000010000000000000AAA /* MapContentView.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.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/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..6ca50da --- /dev/null +++ b/SulTee/SulTee/ContentView.swift @@ -0,0 +1,178 @@ +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 + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + connectionBanner + orinURLField + Divider() + sensorRatesGrid + Divider() + if let dist = sensor.distanceToRobot { + distanceRow(dist) + } + Spacer() + followMeButton + } + .padding() + .navigationTitle("SAUL-T-MOTE") + .onAppear { editingURL = orinURL } + } + } + + // MARK: Connection banner + + private var connectionBanner: some View { + HStack(spacing: 12) { + Circle() + .fill(wsColor) + .frame(width: 14, height: 14) + Text(wsLabel) + .font(.headline) + Spacer() + } + .padding(.top, 8) + } + + // MARK: Orin URL field + + private var orinURLField: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Orin WebSocket URL") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("ws://host:port", text: $editingURL) + .textFieldStyle(.roundedBorder) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($urlFieldFocused) + .disabled(sensor.isStreaming) + .onSubmit { applyURL() } + if !sensor.isStreaming { + Button("Apply") { applyURL() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + } + } + + private func applyURL() { + urlFieldFocused = false + orinURL = editingURL + sensor.updateURL(editingURL) + } + + // MARK: Sensor rates grid + + private var sensorRatesGrid: some View { + Grid(horizontalSpacing: 20, verticalSpacing: 12) { + GridRow { + rateCell(icon: "location.fill", label: "GPS", rate: sensor.gpsRate) + rateCell(icon: "gyroscope", label: "IMU", rate: sensor.imuRate) + } + GridRow { + rateCell(icon: "location.north.fill", label: "Heading", rate: sensor.headingRate) + rateCell(icon: "barometer", label: "Baro", rate: sensor.baroRate) + } + } + } + + private func rateCell(icon: String, label: String, rate: Double) -> some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(rate > 0 ? .green : .secondary) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(Int(rate)) Hz") + .font(.title3.monospacedDigit()) + .bold() + } + .frame(maxWidth: .infinity) + .padding() + .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) + } + + // MARK: Distance row + + private func distanceRow(_ dist: Double) -> some View { + HStack { + 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 { + sensor.isStreaming ? sensor.stopStreaming() : sensor.startStreaming() + } label: { + Label( + sensor.isStreaming ? "Stop Follow-Me" : "Start Follow-Me", + systemImage: sensor.isStreaming ? "stop.circle.fill" : "play.circle.fill" + ) + .font(.title2.bold()) + .frame(maxWidth: .infinity) + .padding() + .background(sensor.isStreaming ? Color.red : Color.accentColor, + in: RoundedRectangle(cornerRadius: 16)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .padding(.bottom, 8) + } + + // MARK: Helpers + + private var wsColor: Color { + switch sensor.wsState { + case .connected: return .green + case .connecting: return .yellow + case .disconnected: return .red + } + } + + private var wsLabel: String { + switch sensor.wsState { + case .connected: return "Connected — \(orinURL)" + case .connecting: return "Connecting…" + case .disconnected: return "Disconnected" + } + } +} + +#Preview { + ContentView() + .environmentObject(SensorManager()) +} diff --git a/SulTee/SulTee/Info.plist b/SulTee/SulTee/Info.plist new file mode 100644 index 0000000..d00c04b --- /dev/null +++ b/SulTee/SulTee/Info.plist @@ -0,0 +1,80 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + SAUL-T-MOTE + 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 + + NSAppTransportSecurity + + + NSAllowsLocalNetworking + + NSExceptionDomains + + 100.64.0.2 + + NSExceptionAllowsInsecureHTTPLoads + + + 192.168.87.29 + + NSExceptionAllowsInsecureHTTPLoads + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + diff --git a/SulTee/SulTee/MQTTClient.swift b/SulTee/SulTee/MQTTClient.swift new file mode 100644 index 0000000..8072714 --- /dev/null +++ b/SulTee/SulTee/MQTTClient.swift @@ -0,0 +1,240 @@ +import Foundation +import Network + +/// 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 { + 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 + + /// 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) { + 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 } + 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 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() + } + + 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 + + private func scheduleRead() { + 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 error == nil { self.scheduleRead() } + } + } + + /// Parse one or more MQTT packets from `data`. + private func handleIncoming(_ data: Data) { + 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") + 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) + } + + 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) + } + + 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 { + 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/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 new file mode 100644 index 0000000..41db57e --- /dev/null +++ b/SulTee/SulTee/SensorManager.swift @@ -0,0 +1,331 @@ +import Foundation +import CoreLocation +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. +final class SensorManager: NSObject, ObservableObject { + + // MARK: - Streaming state + + @Published var isStreaming = false + @Published var wsState: WebSocketClient.ConnectionState = .disconnected + + // 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 + + var orinURLString: String { + 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: "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? + + // MARK: - Sensors + + private let locationManager = CLLocationManager() + private let motionManager = CMMotionManager() + private let altimeter = CMAltimeter() + private var cancellables = Set() + + // MARK: - Rate counters + + private var gpsCounts: [Date] = [] + private var imuCounts: [Date] = [] + private var headingCounts: [Date] = [] + private var baroCounts: [Date] = [] + 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)!) + super.init() + + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation + 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) + + mqtt.onMessage = { [weak self] topic, payload in + guard let self, topic == Self.robotGPSTopic else { return } + self.handleRobotGPS(payload) + } + } + + // MARK: - Public control + + func startStreaming() { + guard !isStreaming else { return } + isStreaming = true + ws.connect() + mqtt.connect() + mqtt.subscribe(topic: Self.robotGPSTopic) + requestPermissionsAndStartSensors() + startRateTimer() + startMQTTPublishTimer() + } + + func stopStreaming() { + guard isStreaming else { return } + isStreaming = false + ws.disconnect() + mqtt.disconnect() + stopSensors() + rateTimer?.invalidate(); rateTimer = nil + mqttPublishTimer?.invalidate(); mqttPublishTimer = nil + } + + 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: - 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.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 + + 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, _ in + guard let self, let motion else { return } + self.recordEvent(in: &self.imuCounts) + self.ws.send([ + "type": "imu", + "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], + "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, _ 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 } + 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 + ] + ]) + } + + 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..5c8e2e9 --- /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 + + var 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() + } +}