diff --git a/.DS_Store b/.DS_Store index d98ebb1..f45a06b 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 330d167..3514bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,5 @@ fastlane/test_output # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ + +*.DS_Store diff --git a/ios_app/iwatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/ios_app/iwatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios_app/iwatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/iwatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios_app/iwatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/ios_app/iwatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/iwatch Watch App/Assets.xcassets/Contents.json b/ios_app/iwatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios_app/iwatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/iwatch Watch App/ContentView.swift b/ios_app/iwatch Watch App/ContentView.swift new file mode 100644 index 0000000..2e0c0b0 --- /dev/null +++ b/ios_app/iwatch Watch App/ContentView.swift @@ -0,0 +1,114 @@ +// +// ContentView.swift +// iwatch Watch App +// +// Created by 梁丰洲 on 2024/12/15. +// + +import SwiftUI +import WatchConnectivity + +func quaternionToEulerAngles(w: Double, x: Double, y: Double, z: Double) -> (roll: Double, pitch: Double, yaw: Double) { + let sinr_cosp = 2.0 * (w * x + y * z) + let cosr_cosp = 1.0 - 2.0 * (x * x + y * y) + let roll = atan2(sinr_cosp, cosr_cosp) + + let sinp = 2.0 * (w * y - z * x) + let pitch: Double + if abs(sinp) >= 1 { + pitch = sinp > 0 ? Double.pi / 2 : -Double.pi / 2 + } else { + pitch = asin(sinp) + } + + let siny_cosp = 2.0 * (w * z + x * y) + let cosy_cosp = 1.0 - 2.0 * (y * y + z * z) + let yaw = atan2(siny_cosp, cosy_cosp) + + return (roll, pitch, yaw) +} + +struct ContentView: View { + @State private var logStarting = false + @State private var showAlert = false + @ObservedObject var sensorLogger = WatchSensorManager.shared + + var body: some View { + VStack { + Text("Watch IMU Recorder") + Button(action: { + if !WCSession.default.isReachable { + self.showAlert = true + return + } + + self.logStarting.toggle() + + if self.logStarting { + var samplingFrequency = UserDefaults.standard.integer(forKey: "frequency_preference") + + if samplingFrequency <= 0 { + samplingFrequency = 30 + } + + print("sampling frequency = \(samplingFrequency) on watch") + self.sensorLogger.startUpdate(Double(samplingFrequency)) + } + else { + self.sensorLogger.stopUpdate() + } + }) { + if self.logStarting { + Image(systemName: "pause.circle") + Text("stop pushing") + } + else { + Image(systemName: "play.circle") + Text("Push data") + } + }.alert(isPresented: $showAlert) { + Alert(title: Text("Watch Not Active"), message: Text("Please activate your watch by raising your wrist before pushing data."), dismissButton: .default(Text("OK"))) + } + } + + VStack { + VStack { + Text("Accelerometer").font(.headline) + HStack { + Text(String(format: "%.2f", self.sensorLogger.accX)) + Spacer() + Text(String(format: "%.2f", self.sensorLogger.accY)) + Spacer() + Text(String(format: "%.2f", self.sensorLogger.accZ)) + }.padding(.horizontal) + } + + VStack { + Text("Gyroscope").font(.headline) + HStack { + Text(String(format: "%.2f", self.sensorLogger.gyrX)) + Spacer() + Text(String(format: "%.2f", self.sensorLogger.gyrX)) + Spacer() + Text(String(format: "%.2f", self.sensorLogger.gyrX)) + }.padding(.horizontal) + } + + VStack { + HStack { + let (roll, pitch, yaw) = quaternionToEulerAngles(w: self.sensorLogger.qatW, x: self.sensorLogger.qatX, y: self.sensorLogger.qatY, z: self.sensorLogger.qatZ) + + Text(String(format: "%.2f", roll * 180.0 / Double.pi)) + Spacer() + Text(String(format: "%.2f", pitch * 180.0 / Double.pi)) + Spacer() + Text(String(format: "%.2f", yaw * 180.0 / Double.pi)) + }.padding(.horizontal) + } + } + } +} + +#Preview { + ContentView() +} diff --git a/ios_app/iwatch Watch App/ExtendedRuntimeManager.swift b/ios_app/iwatch Watch App/ExtendedRuntimeManager.swift new file mode 100644 index 0000000..f612e70 --- /dev/null +++ b/ios_app/iwatch Watch App/ExtendedRuntimeManager.swift @@ -0,0 +1,53 @@ +// +// ExtendedRuntimeManager.swift +// phone +// +// Created by 梁丰洲 on 2024/12/16. +// + +import WatchKit + +class ExtendedRuntimeManager: NSObject, WKExtendedRuntimeSessionDelegate { + static let shared = ExtendedRuntimeManager() + + private var session: WKExtendedRuntimeSession? + + private override init() { + super.init() + } + + func startSession() { + guard session == nil else { return } + + session = WKExtendedRuntimeSession() + session?.delegate = self + session?.start() + + print("Extended Runtime Session started.") + } + + func endSession() { + session?.invalidate() + session = nil + + print("Extended Runtime Session ended.") + } + + // MARK: - WKExtendedRuntimeSessionDelegate + + func extendedRuntimeSessionDidStart(_ extendedRuntimeSession: WKExtendedRuntimeSession) { + print("Extended Runtime Session did start.") + } + + func extendedRuntimeSessionWillExpire(_ extendedRuntimeSession: WKExtendedRuntimeSession) { + print("Extended Runtime Session will expire.") + } + + func extendedRuntimeSession(_ extendedRuntimeSession: WKExtendedRuntimeSession, didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason, error: Error?) { + print("Extended Runtime Session invalidated: \(reason.rawValue), error: \(String(describing: error))") + session = nil + if reason == .expired { + startSession() + } + } +} diff --git a/ios_app/iwatch Watch App/PhoneConnector.swift b/ios_app/iwatch Watch App/PhoneConnector.swift new file mode 100644 index 0000000..3233feb --- /dev/null +++ b/ios_app/iwatch Watch App/PhoneConnector.swift @@ -0,0 +1,40 @@ +// +// PhoneConnector.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import WatchConnectivity +import WatchKit + +class PhoneConnector: NSObject, WCSessionDelegate { + + override init() { + super.init() + if WCSession.isSupported() { + WCSession.default.delegate = self + WCSession.default.activate() + } + } + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + print("watch activationDidCompleteWith state = \(activationState.rawValue)") + } + + func sessionReachabilityDidChange(_ session: WCSession) { + // 当iPhone或Watch端连通性改变时会调用 + print("watch sessionReachabilityDidChange") + } + + func sendPhoneMessage(data: [String: Any]) { + let session = WCSession.default + if session.isReachable { + session.sendMessage(data, replyHandler: nil, errorHandler: { (error) in + print("watch send message to iPhone error: \(error.localizedDescription)") + }) + } else { + print("watch session is not reachable") + } + } +} diff --git a/ios_app/iwatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json b/ios_app/iwatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios_app/iwatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/iwatch Watch App/WatchSensorManager.swift b/ios_app/iwatch Watch App/WatchSensorManager.swift new file mode 100644 index 0000000..ab692c2 --- /dev/null +++ b/ios_app/iwatch Watch App/WatchSensorManager.swift @@ -0,0 +1,149 @@ +// +// WatchSensorManager.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import Foundation +import CoreMotion +import Combine +import WatchKit + + +struct WatchSensorData: Codable { + let timestamp: TimeInterval + let accX: Double + let accY: Double + let accZ: Double + let gyrX: Double + let gyrY: Double + let gyrZ: Double + let qatX: Double + let qatY: Double + let qatZ: Double + let qatW: Double +} + +class WatchSensorManager: NSObject, ObservableObject { + static let shared = WatchSensorManager() + let motionManager: CMMotionManager + let phoneConnector: PhoneConnector + let encoder: PropertyListEncoder + + // runtime + var timer: Timer? + + @Published var accX = 0.0 + @Published var accY = 0.0 + @Published var accZ = 0.0 + @Published var gyrX = 0.0 + @Published var gyrY = 0.0 + @Published var gyrZ = 0.0 + @Published var qatX = 0.0 + @Published var qatY = 0.0 + @Published var qatZ = 0.0 + @Published var qatW = 0.0 + + private override init() { + self.motionManager = CMMotionManager() + self.phoneConnector = PhoneConnector() + self.encoder = PropertyListEncoder() + self.encoder.outputFormat = .binary + super.init() + } + + func sendWatchSensorData() { + let sensorData = WatchSensorData( + timestamp: Date().timeIntervalSince1970, + accX: self.accX, + accY: self.accY, + accZ: self.accZ, + gyrX: self.gyrX, + gyrY: self.gyrY, + gyrZ: self.gyrZ, + qatX: self.qatX, + qatY: self.qatY, + qatZ: self.qatZ, + qatW: self.qatW + ) + + do { + let encodedData = try encoder.encode(sensorData) + phoneConnector.sendPhoneMessage(data: ["WATCH_SENSOR_DATA": encodedData]) + } catch { + print("Failed to encode sensor data: \(error)") + } + } + + func startUpdate(_ freq: Double) { + let interval = 1.0 / freq + + // MotionManager + if motionManager.isDeviceMotionAvailable { + motionManager.deviceMotionUpdateInterval = interval + motionManager.startDeviceMotionUpdates() + } + + // Timer + self.timer = Timer.scheduledTimer(timeInterval: interval, + target: self, + selector: #selector(self.onLogSensor), + userInfo: nil, + repeats: true) + + // ExtendedRuntime + ExtendedRuntimeManager.shared.startSession() + + // HealthKit + WorkoutManager.shared.startWorkout() + } + + func stopUpdate() { + // ExtendedRuntime + ExtendedRuntimeManager.shared.endSession() + + // HealthKit + WorkoutManager.shared.endWorkout() + + // Timer + self.timer?.invalidate() + + // MotionManager + if motionManager.isDeviceMotionActive { + motionManager.stopDeviceMotionUpdates() + } + } + + @objc private func onLogSensor() { + if let data = motionManager.deviceMotion { + self.accX = data.userAcceleration.x + self.accY = data.userAcceleration.y + self.accZ = data.userAcceleration.z + + self.gyrX = data.rotationRate.x + self.gyrY = data.rotationRate.y + self.gyrZ = data.rotationRate.z + + self.qatX = data.attitude.quaternion.x + self.qatY = data.attitude.quaternion.y + self.qatZ = data.attitude.quaternion.z + self.qatW = data.attitude.quaternion.w + + sendWatchSensorData() + } else { + self.accX = Double.nan + self.accY = Double.nan + self.accZ = Double.nan + self.gyrX = Double.nan + self.gyrY = Double.nan + self.gyrZ = Double.nan + self.qatX = Double.nan + self.qatY = Double.nan + self.qatZ = Double.nan + self.qatW = Double.nan + } + + } + +} diff --git a/ios_app/iwatch Watch App/WorkoutManager.swift b/ios_app/iwatch Watch App/WorkoutManager.swift new file mode 100644 index 0000000..e5fdc51 --- /dev/null +++ b/ios_app/iwatch Watch App/WorkoutManager.swift @@ -0,0 +1,66 @@ +// +// WokeoutManager.swift +// phone +// +// Created by 梁丰洲 on 2024/12/16. +// + +import HealthKit + +class WorkoutManager: NSObject, HKWorkoutSessionDelegate { + static let shared = WorkoutManager() + private var workoutSession: HKWorkoutSession? + + private override init() { + super.init() + } + + func startWorkout() { + let configuration = HKWorkoutConfiguration() + configuration.activityType = .other // 根据实际情况选择,如 .running、.cycling 等 + configuration.locationType = .indoor // 根据实际情况选择 + + let healthStore = HKHealthStore() + + do { + workoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration) + workoutSession?.delegate = self + workoutSession?.startActivity(with: nil) + print("Workout Session START!") + } catch { + print("Cannot launch Workout Session: \(error.localizedDescription)") + } + } + + func endWorkout() { + if let session = workoutSession { + session.end() + print("Workout Session END!") + } + } + + // MARK: - HKWorkoutSessionDelegate + + func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { + switch toState { + case .running: + startDataCollection() + case .ended: + stopDataCollection() + default: + break + } + } + + func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { + print("Workout Session 失败: \(error.localizedDescription)") + } + + // MARK: - 数据采集 + func startDataCollection() { + } + + func stopDataCollection() { + } + +} diff --git a/ios_app/iwatch Watch App/iwatch Watch App.entitlements b/ios_app/iwatch Watch App/iwatch Watch App.entitlements new file mode 100644 index 0000000..54bc426 --- /dev/null +++ b/ios_app/iwatch Watch App/iwatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.background-delivery + + + diff --git a/ios_app/iwatch Watch App/iwatchApp.swift b/ios_app/iwatch Watch App/iwatchApp.swift new file mode 100644 index 0000000..60334dc --- /dev/null +++ b/ios_app/iwatch Watch App/iwatchApp.swift @@ -0,0 +1,17 @@ +// +// iwatchApp.swift +// iwatch Watch App +// +// Created by 梁丰洲 on 2024/12/15. +// + +import SwiftUI + +@main +struct iwatch_Watch_AppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios_app/iwatch-Watch-App-Info.plist b/ios_app/iwatch-Watch-App-Info.plist new file mode 100644 index 0000000..81c94e4 --- /dev/null +++ b/ios_app/iwatch-Watch-App-Info.plist @@ -0,0 +1,15 @@ + + + + + UIBackgroundModes + + location + + WKBackgroundModes + + self-care + workout-processing + + + diff --git a/ios_app/phone.xcodeproj/project.pbxproj b/ios_app/phone.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0db25d8 --- /dev/null +++ b/ios_app/phone.xcodeproj/project.pbxproj @@ -0,0 +1,505 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + F023A8972D0E966C00849073 /* iwatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = F023A88A2D0E966A00849073 /* iwatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + F023A8952D0E966C00849073 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F023A86D2D0E95CF00849073 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F023A8892D0E966A00849073; + remoteInfo = "iwatch Watch App"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F023A89B2D0E966C00849073 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + F023A8972D0E966C00849073 /* iwatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + F023A8752D0E95CF00849073 /* phone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = phone.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F023A88A2D0E966A00849073 /* iwatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "iwatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F023A8772D0E95CF00849073 /* phone */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = phone; + sourceTree = ""; + }; + F023A88B2D0E966A00849073 /* iwatch Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "iwatch Watch App"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F023A8722D0E95CF00849073 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F023A8872D0E966A00849073 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F023A86C2D0E95CF00849073 = { + isa = PBXGroup; + children = ( + F023A8772D0E95CF00849073 /* phone */, + F023A88B2D0E966A00849073 /* iwatch Watch App */, + F023A8762D0E95CF00849073 /* Products */, + ); + sourceTree = ""; + }; + F023A8762D0E95CF00849073 /* Products */ = { + isa = PBXGroup; + children = ( + F023A8752D0E95CF00849073 /* phone.app */, + F023A88A2D0E966A00849073 /* iwatch Watch App.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F023A8742D0E95CF00849073 /* phone */ = { + isa = PBXNativeTarget; + buildConfigurationList = F023A8832D0E95D100849073 /* Build configuration list for PBXNativeTarget "phone" */; + buildPhases = ( + F023A8712D0E95CF00849073 /* Sources */, + F023A8722D0E95CF00849073 /* Frameworks */, + F023A8732D0E95CF00849073 /* Resources */, + F023A89B2D0E966C00849073 /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + F023A8962D0E966C00849073 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F023A8772D0E95CF00849073 /* phone */, + ); + name = phone; + packageProductDependencies = ( + ); + productName = phone; + productReference = F023A8752D0E95CF00849073 /* phone.app */; + productType = "com.apple.product-type.application"; + }; + F023A8892D0E966A00849073 /* iwatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = F023A8982D0E966C00849073 /* Build configuration list for PBXNativeTarget "iwatch Watch App" */; + buildPhases = ( + F023A8862D0E966A00849073 /* Sources */, + F023A8872D0E966A00849073 /* Frameworks */, + F023A8882D0E966A00849073 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F023A88B2D0E966A00849073 /* iwatch Watch App */, + ); + name = "iwatch Watch App"; + packageProductDependencies = ( + ); + productName = "iwatch Watch App"; + productReference = F023A88A2D0E966A00849073 /* iwatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F023A86D2D0E95CF00849073 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + F023A8742D0E95CF00849073 = { + CreatedOnToolsVersion = 16.2; + }; + F023A8892D0E966A00849073 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = F023A8702D0E95CF00849073 /* Build configuration list for PBXProject "phone" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F023A86C2D0E95CF00849073; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = F023A8762D0E95CF00849073 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F023A8742D0E95CF00849073 /* phone */, + F023A8892D0E966A00849073 /* iwatch Watch App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F023A8732D0E95CF00849073 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F023A8882D0E966A00849073 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F023A8712D0E95CF00849073 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F023A8862D0E966A00849073 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F023A8962D0E966C00849073 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F023A8892D0E966A00849073 /* iwatch Watch App */; + targetProxy = F023A8952D0E966C00849073 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F023A8812D0E95D100849073 /* 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_SELF = 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 = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F023A8822D0E95D100849073 /* 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_SELF = 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 = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F023A8842D0E95D100849073 /* 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 = "\"phone/Preview Content\""; + DEVELOPMENT_TEAM = ZR7SZVNKWV; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSMotionUsageDescription = "usd to collect IMU data for motion detection"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sybil.imu-proj.phone"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F023A8852D0E95D100849073 /* 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 = "\"phone/Preview Content\""; + DEVELOPMENT_TEAM = ZR7SZVNKWV; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSMotionUsageDescription = "usd to collect IMU data for motion detection"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sybil.imu-proj.phone"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + F023A8992D0E966C00849073 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "iwatch Watch App/iwatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"iwatch Watch App/Preview Content\""; + DEVELOPMENT_TEAM = ZR7SZVNKWV; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "iwatch-Watch-App-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = iwatch; + INFOPLIST_KEY_NSHealthClinicalHealthRecordsShareUsageDescription = "We need to update your health data in order to record and analyze your exercise data."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "We need access to your health data in order to record your movement data."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "We need to update your health data in order to record and analyze your exercise data."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "sybil.imu-proj.phone"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sybil.imu-proj.phone.watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.4; + }; + name = Debug; + }; + F023A89A2D0E966C00849073 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "iwatch Watch App/iwatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"iwatch Watch App/Preview Content\""; + DEVELOPMENT_TEAM = ZR7SZVNKWV; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "iwatch-Watch-App-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = iwatch; + INFOPLIST_KEY_NSHealthClinicalHealthRecordsShareUsageDescription = "We need to update your health data in order to record and analyze your exercise data."; + INFOPLIST_KEY_NSHealthShareUsageDescription = "We need access to your health data in order to record your movement data."; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "We need to update your health data in order to record and analyze your exercise data."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "sybil.imu-proj.phone"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sybil.imu-proj.phone.watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.4; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F023A8702D0E95CF00849073 /* Build configuration list for PBXProject "phone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F023A8812D0E95D100849073 /* Debug */, + F023A8822D0E95D100849073 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F023A8832D0E95D100849073 /* Build configuration list for PBXNativeTarget "phone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F023A8842D0E95D100849073 /* Debug */, + F023A8852D0E95D100849073 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F023A8982D0E966C00849073 /* Build configuration list for PBXNativeTarget "iwatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F023A8992D0E966C00849073 /* Debug */, + F023A89A2D0E966C00849073 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = F023A86D2D0E95CF00849073 /* Project object */; +} diff --git a/ios_app/phone.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios_app/phone.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios_app/phone.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios_app/phone/Assets.xcassets/AccentColor.colorset/Contents.json b/ios_app/phone/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios_app/phone/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/phone/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios_app/phone/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios_app/phone/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/phone/Assets.xcassets/Contents.json b/ios_app/phone/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios_app/phone/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/phone/ContentView.swift b/ios_app/phone/ContentView.swift new file mode 100644 index 0000000..a63dd6d --- /dev/null +++ b/ios_app/phone/ContentView.swift @@ -0,0 +1,233 @@ +// +// ContentView.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import SwiftUI + +struct ContentView: View { + @ObservedObject private var watchConnector = WatchConnector() + @ObservedObject private var sensorLogger = PhoneSensorManager() + @ObservedObject private var clientSocket = SocketClient() + + @State private var host = "100.64.1.26" + @State private var port = "12345" + @State private var logStarting = false + @State private var backgroundTaskID: UIBackgroundTaskIdentifier? + + func logAction(_ LargeFeedback: Bool) { + self.logStarting.toggle() + + let switchFeedback: UIImpactFeedbackGenerator + if LargeFeedback { + switchFeedback = UIImpactFeedbackGenerator(style: .heavy) + } + else { + switchFeedback = UIImpactFeedbackGenerator(style: .medium) + } + switchFeedback.impactOccurred() + + if self.logStarting { + // background task + self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(expirationHandler: nil) + + // start to measure + var samplingFrequency = UserDefaults.standard.integer(forKey: "frequency_preference") + if samplingFrequency <= 0 { + samplingFrequency = 30 + } + + print("sampling frequency = \(samplingFrequency)") + + self.sensorLogger.startUpdate(Double(samplingFrequency)) + } + else { + self.sensorLogger.stopUpdate() + if let backgroundTaskID = self.backgroundTaskID { + UIApplication.shared.endBackgroundTask(backgroundTaskID) + } + } + } + + var body: some View { + VStack { + VStack{ + HStack{ + Spacer() + Text("IP") + TextField("server ip", text: $host) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding(.vertical, 4) + .disabled(clientSocket.isConnected || clientSocket.isConnecting) + Spacer() + Text("Port") + TextField("server port", text: $port) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding(.vertical, 4) + .keyboardType(.numberPad) + .disabled(clientSocket.isConnected || clientSocket.isConnecting) + Spacer() + }.padding([.horizontal]) + } + HStack { + Spacer () + // socket state + if clientSocket.isConnected { + HStack { + Image(systemName: "checkmark.circle") + Text("online") + }.foregroundColor(.green) + } else { + if clientSocket.isConnecting { + HStack { + Image(systemName: "arrow.2.circlepath") + Text("connecting") + }.foregroundColor(.yellow) + } else { + HStack { + Image(systemName: "xmark.circle") + Text("offline") + }.foregroundColor(.red) + } + } + Spacer () + // server button + if clientSocket.isConnected || clientSocket.isConnecting { + Button(action: { + clientSocket.disconnect() + }) { + HStack { + Image(systemName: "server.rack") + Text("disconnect") + } + } + } else { + Button(action: { + guard let portNum = UInt16(port) else { return } + clientSocket.connect(host: host, port: portNum) + }) { + HStack { + Image(systemName: "server.rack") + Text("conect") + } + } + } + Spacer () + // start or stop + Button(action: {}) { + if self.logStarting { + // double click or long touch + HStack { + Image(systemName: "pause.circle") + Text("Stop") + } + .onTapGesture(count: 2, perform: { + self.logAction(true) + }) + .onLongPressGesture { + self.logAction(true) + } + } + else { + HStack { + Image(systemName: "play.circle") + Text("Start") + }.onTapGesture { + self.logAction(false) + } + + } + } + Spacer () + } + .padding(.vertical) + + // 根据设备类型(iPhone/iPad)显示对应数据 + if UIDevice.current.userInterfaceIdiom == .phone { + deviceView( + deviceName: "iPhone", + deviceIcon: "iphone", + sensorData: sensorLogger.phoneSensorData + ) + } else if UIDevice.current.userInterfaceIdiom == .pad { + deviceView( + deviceName: "iPad", + deviceIcon: "ipad", + sensorData: sensorLogger.phoneSensorData + ) + } + + // Watch数据(非iPad时显示) + if UIDevice.current.userInterfaceIdiom != .pad { + deviceView( + deviceName: "Watch", + deviceIcon: "applewatch", + sensorData: watchConnector.sensorData?.toSensorData() + ) + } + + // AirPods数据 + deviceView( + deviceName: "AirPods Pro", + deviceIcon: "airpodspro", + sensorData: sensorLogger.headSensorData + ) + + } + } + + + func deviceView(deviceName: String, deviceIcon: String, sensorData: SensorData?) -> some View { + VStack { + HStack { + Image(systemName: deviceIcon) + Text(deviceName).font(.headline) + } + + HStack { + Image(systemName: "speedometer") + Spacer() + Text(String(format: "%.3f", sensorData?.accX ?? Double.nan)) + Spacer() + Text(String(format: "%.3f", sensorData?.accY ?? Double.nan)) + Spacer() + Text(String(format: "%.3f", sensorData?.accZ ?? Double.nan)) + } + .padding(.horizontal, 30) + .padding(.vertical, 2) + + HStack { + Image(systemName: "gyroscope") + Spacer() + Text(String(format: "%.3f", sensorData?.gyrX ?? Double.nan)) + Spacer() + Text(String(format: "%.3f", sensorData?.gyrY ?? Double.nan)) + Spacer() + Text(String(format: "%.3f", sensorData?.gyrZ ?? Double.nan)) + } + .padding(.horizontal, 30) + .padding(.vertical, 2) + + HStack { + let (roll, pitch, yaw) = sensorData?.quaternionToEulerAngles() ?? (Double.nan, Double.nan, Double.nan) + Image(systemName: "rotate.3d") + Spacer() + Text(String(format: "%.3f", roll * 180.0 / Double.pi)) + Spacer() + Text(String(format: "%.3f", pitch * 180.0 / Double.pi)) + Spacer() + Text(String(format: "%.3f", yaw * 180.0 / Double.pi)) + } + .padding(.horizontal, 30) + .padding(.vertical, 2) + } + .padding(.vertical, 10) + } + +} + +#Preview { + ContentView() +} diff --git a/ios_app/phone/PhoneSensorManager.swift b/ios_app/phone/PhoneSensorManager.swift new file mode 100644 index 0000000..8b6c590 --- /dev/null +++ b/ios_app/phone/PhoneSensorManager.swift @@ -0,0 +1,104 @@ +// +// PhoneSensorManager.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import Foundation +import CoreMotion +import Combine + +class PhoneSensorManager: NSObject, ObservableObject { + let motionManager: CMMotionManager + let headphoneMotionManager: CMHeadphoneMotionManager + + // runtime + var timer: Timer? + + @Published var phoneSensorData: SensorData? + @Published var headSensorData: SensorData? + + override init() { + self.motionManager = CMMotionManager() + self.headphoneMotionManager = CMHeadphoneMotionManager() + super.init() + + } + + func startUpdate(_ freq: Double) { + let interval = 1.0 / freq + + if self.motionManager.isDeviceMotionAvailable { + self.motionManager.deviceMotionUpdateInterval = interval + self.motionManager.startDeviceMotionUpdates() + } + + // head phone has a fixed frequency of 10hz? + if self.headphoneMotionManager.isDeviceMotionAvailable { + self.headphoneMotionManager.startDeviceMotionUpdates() + } + + self.timer = Timer.scheduledTimer(timeInterval: interval, + target: self, + selector: #selector(self.onLogSensor), + userInfo: nil, + repeats: true) + } + + func stopUpdate() { + self.timer?.invalidate() + + if self.headphoneMotionManager.isDeviceMotionActive { + self.headphoneMotionManager.stopDeviceMotionUpdates() + } + + + if self.motionManager.isDeviceMotionAvailable { + self.motionManager.stopDeviceMotionUpdates() + } + + } + + @objc private func onLogSensor() { + let timestamp = Date().timeIntervalSince1970 + + // iPhone + if let motion = motionManager.deviceMotion { + let data = SensorData(accX: motion.userAcceleration.x, + accY: motion.userAcceleration.y, + accZ: motion.userAcceleration.z, + gyrX: motion.rotationRate.x, + gyrY: motion.rotationRate.y, + gyrZ: motion.rotationRate.z, + qatX: motion.attitude.quaternion.x, + qatY: motion.attitude.quaternion.y, + qatZ: motion.attitude.quaternion.z, + qatW: motion.attitude.quaternion.w) + self.phoneSensorData = data + SensorDataManager.shared.append(sensorType: .PHONE, data: data, timestamp: timestamp) + } else { + self.phoneSensorData = nil + } + + // AirPods Pro + if let data = self.headphoneMotionManager.deviceMotion { + let data = SensorData(accX: data.userAcceleration.x, + accY: data.userAcceleration.y, + accZ: data.userAcceleration.z, + gyrX: data.rotationRate.x, + gyrY: data.rotationRate.y, + gyrZ: data.rotationRate.z, + qatX: data.attitude.quaternion.x, + qatY: data.attitude.quaternion.y, + qatZ: data.attitude.quaternion.z, + qatW: data.attitude.quaternion.w) + self.headSensorData = data + SensorDataManager.shared.append(sensorType: .HEADSET, data: data, timestamp: timestamp) + } else { + self.headSensorData = nil + } + + } + +} diff --git a/ios_app/phone/Preview Content/Preview Assets.xcassets/Contents.json b/ios_app/phone/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios_app/phone/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios_app/phone/SensorData.swift b/ios_app/phone/SensorData.swift new file mode 100644 index 0000000..bd2607f --- /dev/null +++ b/ios_app/phone/SensorData.swift @@ -0,0 +1,62 @@ +// +// SensorData.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import SwiftUI +import Foundation + +struct SensorDataRecord: Codable { + let timestamp: TimeInterval + let sensorType: SensorType + let data: SensorData +} + +enum SensorType: String, Codable { + case PHONE + case WATCH + case HEADSET +} + +struct SensorData: Codable { + var accX: Double + var accY: Double + var accZ: Double + var gyrX: Double + var gyrY: Double + var gyrZ: Double + var qatX: Double + var qatY: Double + var qatZ: Double + var qatW: Double + + func quaternionToEulerAngles() -> (roll: Double, pitch: Double, yaw: Double) { + let w = self.qatW + let x = self.qatX + let y = self.qatY + let z = self.qatZ + let sinr_cosp = 2.0 * (w * x + y * z) + let cosr_cosp = 1.0 - 2.0 * (x * x + y * y) + let roll = atan2(sinr_cosp, cosr_cosp) + + let sinp = 2.0 * (w * y - z * x) + let pitch: Double + if abs(sinp) >= 1 { + pitch = sinp > 0 ? Double.pi / 2 : -Double.pi / 2 + } else { + pitch = asin(sinp) + } + + let siny_cosp = 2.0 * (w * z + x * y) + let cosy_cosp = 1.0 - 2.0 * (y * y + z * z) + let yaw = atan2(siny_cosp, cosy_cosp) + + return (roll, pitch, yaw) + } + + func toIMUString() -> String { + return "\(self.accX),\(self.accY),\(self.accZ),\(self.qatX),\(self.qatY),\(self.qatZ),\(self.qatW)" + } +} diff --git a/ios_app/phone/SensorDataManager.swift b/ios_app/phone/SensorDataManager.swift new file mode 100644 index 0000000..b2ea798 --- /dev/null +++ b/ios_app/phone/SensorDataManager.swift @@ -0,0 +1,51 @@ +// +// SensorDataManager.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import Foundation + +class SensorDataManager: @unchecked Sendable { + static let shared = SensorDataManager() + + private var records: [SensorDataRecord] = [] + + var latestPhone: SensorDataRecord? + var latestWatch: SensorDataRecord? + var latestHeadset: SensorDataRecord? + + private func appendInternal(record: SensorDataRecord) { +// self.records.append(record) + switch record.sensorType { + case .PHONE: + if latestPhone == nil || record.timestamp > latestPhone!.timestamp { + latestPhone = record + } + case .WATCH: + if latestWatch == nil || record.timestamp > latestWatch!.timestamp { + latestWatch = record + } + case .HEADSET: + if latestHeadset == nil || record.timestamp > latestHeadset!.timestamp { + latestHeadset = record + } + } + } + + func append(sensorType: SensorType, data: SensorData, timestamp: TimeInterval = Date().timeIntervalSince1970) { + let record = SensorDataRecord(timestamp: timestamp, sensorType: sensorType, data: data) + self.appendInternal(record: record) + } + + func allRecords() -> [SensorDataRecord] { + return records + } + + func clear() { + records = [] + } + + private init() {} +} diff --git a/ios_app/phone/SocketClient.swift b/ios_app/phone/SocketClient.swift new file mode 100644 index 0000000..8635f7d --- /dev/null +++ b/ios_app/phone/SocketClient.swift @@ -0,0 +1,115 @@ +// +// SocketClient.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import SwiftUI +import Network + +class SocketClient: ObservableObject { + @Published var isConnected: Bool = false + @Published var isConnecting: Bool = false + + private var connection: NWConnection? + private var sendTask: Task? + + func connect(host: String, port: UInt16) { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + print("invalid port") + return + } + + // connection is in progress + isConnected = false + isConnecting = true + + connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp) + + connection?.stateUpdateHandler = { [weak self] state in + DispatchQueue.main.async { + switch state { + case .ready: + print("connected") + self?.isConnected = true + self?.isConnecting = false + self?.startSending() + case .failed(let error): + print("connection fail: \(error)") + self?.isConnected = false + self?.isConnecting = false + case .waiting(let error): + // 等待连接中,仍保持 isConnecting = true + print("connection waiting: \(error)") + case .cancelled: + print("connection") + self?.isConnected = false + self?.isConnecting = false + default: + break + } + } + } + + connection?.start(queue: .main) + } + + func disconnect() { + connection?.cancel() + connection = nil + isConnected = false + isConnecting = false + stopSending() + } + + func send(data: Data) { + guard let conn = connection, isConnected else { return } + conn.send(content: data, completion: .contentProcessed({ error in + if let error = error { + print("发送出错: \(error)") + } + })) + } + + private func startSending() { + stopSending() // make sure only one sending task is running + sendTask = Task { + let interval = UInt64(1_000_000_000 / 30) // sample rate 30Hz + while !Task.isCancelled { + if isConnected { + var data = "#" + var toSend = false + if let phoneData = SensorDataManager.shared.latestPhone?.data.toIMUString() { + data += phoneData + toSend = true + } + data += "|" + if let watchData = SensorDataManager.shared.latestWatch?.data.toIMUString() { + data += watchData + toSend = true + } + + data += "|" + if let headsetData = SensorDataManager.shared.latestHeadset?.data.toIMUString() { + data += headsetData + toSend = true + } + data += "\n" + + if toSend { + send(data: data.data(using: .utf8)!) + } + } else { + break + } + try? await Task.sleep(nanoseconds: interval) + } + } + } + + private func stopSending() { + sendTask?.cancel() + sendTask = nil + } +} diff --git a/ios_app/phone/WatchConnector.swift b/ios_app/phone/WatchConnector.swift new file mode 100644 index 0000000..545ffa8 --- /dev/null +++ b/ios_app/phone/WatchConnector.swift @@ -0,0 +1,86 @@ +// +// WatchConnector.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import WatchConnectivity + +struct WatchSensorData: Codable { + let timestamp: TimeInterval + let accX: Double + let accY: Double + let accZ: Double + let gyrX: Double + let gyrY: Double + let gyrZ: Double + let qatX: Double + let qatY: Double + let qatZ: Double + let qatW: Double + + func toSensorData() -> SensorData { + return SensorData( + accX: accX, + accY: accY, + accZ: accZ, + gyrX: gyrX, + gyrY: gyrY, + gyrZ: gyrZ, + qatX: qatX, + qatY: qatY, + qatZ: qatZ, + qatW: qatW + ) + } +} + +class WatchConnector: NSObject, ObservableObject, WCSessionDelegate { + @Published var sensorData: WatchSensorData? + let decoder: PropertyListDecoder + + override init() { + self.decoder = PropertyListDecoder() + super.init() + if WCSession.isSupported() { + WCSession.default.delegate = self + WCSession.default.activate() + } + } + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + print("phone activationDidCompleteWith state = \(activationState.rawValue)") + } + + func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { + if let encodedData = message["WATCH_SENSOR_DATA"] as? Data { + do { + let decodedData = try decoder.decode(WatchSensorData.self, from: encodedData) + if sensorData == nil || sensorData!.timestamp < decodedData.timestamp { + // schedule main thread + DispatchQueue.main.async { + self.sensorData = decodedData + } + SensorDataManager.shared.append( + sensorType: .WATCH, + data: decodedData.toSensorData(), + timestamp: decodedData.timestamp) + } + } catch { + print("Failed to decode sensor data: \(error)") + } + } + } + + func sessionDidBecomeInactive(_ session: WCSession) { + print("phone sessionDidBecomeInactive") + } + func sessionDidDeactivate(_ session: WCSession) { + print("phone sessionDidDeactivate") + } + func sessionWatchStateDidChange(_ session: WCSession) { + print("phone sessionWatchStateDidChange") + + } +} diff --git a/ios_app/phone/phoneApp.swift b/ios_app/phone/phoneApp.swift new file mode 100644 index 0000000..356e328 --- /dev/null +++ b/ios_app/phone/phoneApp.swift @@ -0,0 +1,17 @@ +// +// phoneApp.swift +// phone +// +// Created by 梁丰洲 on 2024/12/15. +// + +import SwiftUI + +@main +struct phoneApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +}