feat: publish iOS GPS to MQTT saltybot/ios/gps at 1 Hz (Issue #681) #2
345
SulTee/SulTee.xcodeproj/project.pbxproj
Normal file
345
SulTee/SulTee.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
// !$*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 */; };
|
||||||
|
/* 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; };
|
||||||
|
A100000100000000000009AB /* MQTTClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MQTTClient.swift; sourceTree = "<group>"; };
|
||||||
|
/* 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 */,
|
||||||
|
A100000100000000000009AB /* MQTTClient.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 */,
|
||||||
|
A100000100000000000009AA /* MQTTClient.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 */;
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
SulTee/SulTee/Assets.xcassets/Contents.json
Normal file
6
SulTee/SulTee/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
149
SulTee/SulTee/ContentView.swift
Normal file
149
SulTee/SulTee/ContentView.swift
Normal 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())
|
||||||
|
}
|
||||||
78
SulTee/SulTee/Info.plist
Normal file
78
SulTee/SulTee/Info.plist
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<?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>
|
||||||
|
<key>192.168.87.29</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
187
SulTee/SulTee/MQTTClient.swift
Normal file
187
SulTee/SulTee/MQTTClient.swift
Normal file
@ -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)])
|
||||||
|
}
|
||||||
|
}
|
||||||
299
SulTee/SulTee/SensorManager.swift
Normal file
299
SulTee/SulTee/SensorManager.swift
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
import CoreMotion
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
/// 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: - 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() {
|
||||||
|
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 }
|
||||||
|
lastKnownLocation = loc
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
SulTee/SulTee/SulTeeApp.swift
Normal file
13
SulTee/SulTee/SulTeeApp.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SulTeeApp: App {
|
||||||
|
@StateObject private var sensorManager = SensorManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(sensorManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
SulTee/SulTee/WebSocketClient.swift
Normal file
114
SulTee/SulTee/WebSocketClient.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user