feat: iOS companion app - sensor streaming (Issue #709) #1

Open
sl-ios wants to merge 3 commits from sl-ios/issue-709-companion-app into main
8 changed files with 960 additions and 0 deletions

View File

@ -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 = "<group>"; };
A100000100000000000002AB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A100000100000000000003AB /* SensorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorManager.swift; sourceTree = "<group>"; };
A100000100000000000004AB /* WebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketClient.swift; sourceTree = "<group>"; };
A100000100000000000005AB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A100000100000000000006AB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>";
};
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 = "<group>";
};
A100000100000000000012GR /* Products */ = {
isa = PBXGroup;
children = (
A100000100000000000007AB /* SulTee.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,149 @@
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()
if let dist = sensor.botDistanceMeters {
Text("Bot distance: \(dist, specifier: "%.1f") m")
.font(.title2)
.bold()
}
Spacer()
followMeButton
}
.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()
.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 — \(orinURL)"
case .connecting: return "Connecting…"
case .disconnected: return "Disconnected"
}
}
}
#Preview {
ContentView()
.environmentObject(SensorManager())
}

73
SulTee/SulTee/Info.plist Normal file
View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>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.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Sul-Tee streams your GPS location to SaltyBot for follow-me mode.</string>
<key>NSMotionUsageDescription</key>
<string>Sul-Tee streams IMU and barometer data to SaltyBot for follow-me stabilization.</string>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>external-accessory</string>
</array>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Allow plain ws:// to Tailscale (CGNAT 100.64.0.0/10) and other local addresses.
Error -1022 occurs because ATS blocks non-TLS connections by default.
NSAllowsLocalNetworking covers loopback, link-local, and CGNAT ranges. -->
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>100.64.0.2</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,251 @@
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
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()
private var cancellables = Set<AnyCancellable>()
// 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() {
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
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
}
/// 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() {
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
}
}
}

View File

@ -0,0 +1,13 @@
import SwiftUI
@main
struct SulTeeApp: App {
@StateObject private var sensorManager = SensorManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(sensorManager)
}
}
}

View File

@ -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()
}
}