The Watch App
This is an example app.
Replace the files Xcode generated with the three below at path
ios/CarpWatch Watch App/.
CarpWatchSensorController.swift
Section titled “CarpWatchSensorController.swift”This contains four main parts:
activate() startup
Activates the WCSession, restores whether sampling was running when the app was
last closed, sets the chunk format, asks the phone for settings, and resumes
sampling if it was running.
The columnar chunk format is 3–5× smaller than row-oriented JSON, which directly reduces radio time on both devices.
apply(settings:) configuration handshake
Maps the keys the phone sends onto the AWARE sensor configurations. AWARE itself
applies label, db_host and debug to every registered sensor; everything else
is applied here, because only the watch app knows which sensors exist
start() / stop() sensor lifecycle
Builds the set of enabled sensors, requests HealthKit authorization if heart rate is among them, and starts the background session chosen by the phone
transferNow() sending data to the phone
Runs on a timer at fileTransferInterval, and whenever the app goes to the
background. WCSession.transferFile queues the files, so this works even when
the phone is out of range
import Foundationimport SwiftUIimport WatchConnectivity
import com_awareframework_ios_coreimport com_awareframework_ios_sensor_applewatch_sharedimport com_awareframework_ios_sensor_applewatch_watchOS
/// Runs the AWARE sensors on the watch, configured by the paired iPhone.////// The phone answers a `get_settings` request with the `AppleWatchDevice`/// configuration from the deployed CARP study protocol, so this controller/// holds no study-specific configuration of its own - only defaults used until/// the phone has been heard from.@MainActorfinal class CarpWatchSensorController: NSObject, ObservableObject {
// MARK: - Published state (for the UI)
@Published private(set) var isRunning = false @Published private(set) var statusMessage = "Not started" @Published private(set) var activeSensorCount = 0 @Published private(set) var lastTransferAt: Date? @Published private(set) var isPhoneReachable = false
/// The AWARE device id of this watch. Handy to show in a debug screen. let deviceId = AwareUtils.getCommonDeviceId()
// MARK: - Settings received from the phone
private var backgroundSessionType: AWBackgroundSessionType = .microphone private var transferInterval: TimeInterval = 15 * 60 private var transferIncrementally = true private var deleteAfterTransfer = true
private var enabled: [String: Bool] = [ "motion": true, "battery": true, "device": true, "heartRate": true, "location": false, "heading": false, "bluetooth": false, "audio": false, ]
private var transferTimer: Timer? private var hasAppliedPhoneSettings = false
// MARK: - Sensors
private lazy var motion = AWMotionSensor(AWMotionSensor.Config().apply { config in config.motionSensorHz = 10 config.saveIntervalSeconds = 10 config.activateAccelerometerSensor = true config.activateDeviceMotionSensor = true // Gyroscope and magnetometer are covered by device motion - leaving // them on doubles the data volume for very little extra information. config.activateGyroscopeSensor = false config.activateMagnetometerSensor = false })
private lazy var battery = AWBatterySensor(AWBatterySensor.Config().apply { config in config.intervalSeconds = 60 })
private lazy var device = AWDeviceSensor(AWDeviceSensor.Config())
private lazy var heartRate = AWHeartRateSensor(AWHeartRateSensor.Config())
private lazy var location = AWLocationSensor(AWLocationSensor.Config())
private lazy var heading = AWHeadingSensor(AWHeadingSensor.Config())
private lazy var bluetooth = AWBluetoothSensor(AWBluetoothSensor.Config().apply { config in config.sensingDurationSeconds = 10 config.sleepDurationSeconds = 50 })
private lazy var audio = AWAudioSensor(AWAudioSensor.Config().apply { config in config.activateAmbientNoiseSensor = true config.activateAudioClassificationSensor = true // Never store raw audio - the phone side of this package does not // accept it, and a study that records audio needs a very different // consent process. config.activateRawAudioSensor = false config.dutyCycleEnabled = true config.activeDuration = 60 config.restDuration = 180 config.storeOnlyTopK = 5 })
// MARK: - Lifecycle
/// Called when the watch app appears. Resumes sampling if it was running /// when the app was last closed, and asks the phone for the study settings. func activate() { _ = AWWCSessionManager.shared // activates the WCSession isRunning = UserDefaults.standard.bool(forKey: Self.isRunningKey) updateReachability()
// Chunk size and format only matter for how the data is packed; leave // the columnar format on, it is 3-5x smaller than row-oriented JSON. AWDataTransferManager.shared.useColumnarFormat = true AWDataTransferManager.shared.recordsPerChunk = 500
applyPhoneSettings() if isRunning { start() } }
/// Ask the phone for the study configuration and apply it. /// /// Safe to call at any time - if the phone cannot be reached the call /// simply has no effect, and the sensors keep running with whatever /// configuration they already have. func applyPhoneSettings() { guard WCSession.default.isReachable else { statusMessage = hasAppliedPhoneSettings ? statusMessage : "Waiting for the phone…" return }
AWWCSessionManager.shared.applyiPhoneSettings { [weak self] settings in Task { @MainActor in self?.apply(settings: settings) } } }
/// Map the settings sent by `AppleWatchDevice` onto the AWARE sensors. private func apply(settings: [String: Any]) { // `applyiPhoneSettings` already applied `label`, `db_host`, and `debug` // to every sensor registered in AWSensorManager. Everything below has // to be applied by hand, because only we know which sensors exist.
if let hz = settings["motion_sensor_hz"] as? Int { motion.CONFIG.motionSensorHz = hz } if let value = settings["watch_motion_accelerometer_enabled"] as? Bool { motion.CONFIG.activateAccelerometerSensor = value } if let value = settings["watch_motion_device_motion_enabled"] as? Bool { motion.CONFIG.activateDeviceMotionSensor = value }
if let value = settings["watch_audio_ambient_noise_enabled"] as? Bool { audio.CONFIG.activateAmbientNoiseSensor = value } if let value = settings["watch_audio_classification_enabled"] as? Bool { audio.CONFIG.activateAudioClassificationSensor = value } if let value = settings["watch_audio_duty_cycle_enabled"] as? Bool { audio.CONFIG.dutyCycleEnabled = value } if let value = settings["watch_audio_active_duration"] as? Double { audio.CONFIG.activeDuration = value } if let value = settings["watch_audio_rest_duration"] as? Double { audio.CONFIG.restDuration = value }
enabled["motion"] = settings["watch_motion_enabled"] as? Bool ?? enabled["motion"]! enabled["battery"] = settings["watch_battery_enabled"] as? Bool ?? enabled["battery"]! enabled["device"] = settings["watch_device_enabled"] as? Bool ?? enabled["device"]! enabled["heartRate"] = settings["watch_healthkit_enabled"] as? Bool ?? enabled["heartRate"]! enabled["location"] = settings["watch_location_enabled"] as? Bool ?? enabled["location"]! enabled["heading"] = settings["watch_heading_enabled"] as? Bool ?? enabled["heading"]! enabled["bluetooth"] = settings["watch_bluetooth_enabled"] as? Bool ?? enabled["bluetooth"]! enabled["audio"] = settings["watch_audio_enabled"] as? Bool ?? enabled["audio"]!
backgroundSessionType = AWBackgroundSessionType( rawValueOrDefault: settings["watch_background_session_type"] as? String )
if let seconds = settings["file_transfer_interval_seconds"] as? Double, seconds > 0 { transferInterval = seconds } transferIncrementally = (settings["watch_transfer_mode"] as? String ?? "incremental") == "incremental" deleteAfterTransfer = settings["watch_delete_after_transfer"] as? Bool ?? true
hasAppliedPhoneSettings = true statusMessage = "Configured by the phone"
// Restart so the new configuration takes effect right away. if isRunning { start() } }
// MARK: - Start / stop
func start() { let sensors = selectedSensors() guard !sensors.isEmpty else { stop() statusMessage = "No sensors enabled" return }
AWSensorManager.shared.set(sensors: sensors) { [weak self] in guard let self else { return }
// Asks for HealthKit authorization if a heart rate sensor is in the // set. On watchOS the prompt is shown on the watch. AWSensorManager.shared.requestPermissionHealthKit { _, _ in }
AWSensorManager.shared.start(backgroundSessionType: self.backgroundSessionType) { Task { @MainActor in self.isRunning = true self.activeSensorCount = sensors.count self.statusMessage = "Collecting from \(sensors.count) sensors" self.persistRunningState() self.scheduleTransfers() self.updateReachability() } } } }
func stop() { transferTimer?.invalidate() transferTimer = nil
AWSensorManager.shared.stop { [weak self] in Task { @MainActor in guard let self else { return } self.isRunning = false self.activeSensorCount = 0 self.statusMessage = "Stopped" self.persistRunningState() } } }
func toggle() { isRunning ? stop() : start() }
private func selectedSensors() -> [AwareSensor] { var sensors: [AwareSensor] = [] if enabled["motion"] == true { sensors.append(motion) } if enabled["battery"] == true { sensors.append(battery) } if enabled["device"] == true { sensors.append(device) } if enabled["heartRate"] == true { sensors.append(heartRate) } if enabled["location"] == true { sensors.append(location) } if enabled["heading"] == true { sensors.append(heading) } if enabled["bluetooth"] == true { sensors.append(bluetooth) } if enabled["audio"] == true { sensors.append(audio) } return sensors }
// MARK: - Transferring data to the phone
private func scheduleTransfers() { transferTimer?.invalidate() transferTimer = Timer.scheduledTimer( withTimeInterval: transferInterval, repeats: true ) { [weak self] _ in Task { @MainActor in self?.transferNow() } } }
/// Hand everything collected so far to the phone. /// /// `WCSession.transferFile` queues the files, so this works even when the /// phone is out of range - the OS delivers them when it comes back. func transferNow() { let sensors = selectedSensors() guard !sensors.isEmpty else { return }
statusMessage = "Transferring…"
let completion: (Error?) -> Void = { [weak self] error in Task { @MainActor in guard let self else { return } if let error { self.statusMessage = "Transfer failed: \(error.localizedDescription)" } else { self.lastTransferAt = Date() self.statusMessage = self.isRunning ? "Collecting from \(self.activeSensorCount) sensors" : "Transfer complete" } self.updateReachability() } }
if transferIncrementally { AWSensorManager.shared.transferIncrementalData( deleteAfterTransfer: deleteAfterTransfer, completion: completion ) } else { AWSensorManager.shared.transferAllData( deleteAfterTransfer: deleteAfterTransfer, completion: completion ) } }
// MARK: - Helpers
private static let isRunningKey = "carp_watch_is_running"
private func persistRunningState() { UserDefaults.standard.set(isRunning, forKey: Self.isRunningKey) }
private func updateReachability() { isPhoneReachable = WCSession.isSupported() && WCSession.default.isReachable }}CarpWatchApp.swift
Section titled “CarpWatchApp.swift”import SwiftUI
@mainstruct CarpWatchApp: App { @StateObject private var controller = CarpWatchSensorController() @Environment(\.scenePhase) private var scenePhase
var body: some Scene { WindowGroup { ContentView(controller: controller) .onAppear { controller.activate() } } .onChange(of: scenePhase) { _, phase in // Hand whatever has been collected to the phone before the app is // suspended, so nothing is left behind if the app is terminated. if phase == .background { controller.transferNow() } } }}ContentView.swift
Section titled “ContentView.swift”import SwiftUI
struct ContentView: View { @ObservedObject var controller: CarpWatchSensorController
var body: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { Label( controller.isRunning ? "Collecting" : "Stopped", systemImage: controller.isRunning ? "waveform.path.ecg" : "pause.circle" ) .font(.headline)
Text(controller.statusMessage) .font(.footnote) .foregroundStyle(.secondary)
Label( controller.isPhoneReachable ? "Phone reachable" : "Phone out of range", systemImage: controller.isPhoneReachable ? "iphone.radiowaves.left.and.right" : "iphone.slash" ) .font(.caption2) .foregroundStyle(.secondary)
if let lastTransferAt = controller.lastTransferAt { Text("Last transfer: \(lastTransferAt.formatted(date: .omitted, time: .shortened))") .font(.caption2) .foregroundStyle(.secondary) }
Button(controller.isRunning ? "Stop" : "Start") { controller.toggle() } .tint(controller.isRunning ? .red : .green)
Button("Send data to phone") { controller.transferNow() }
Button("Reload settings") { controller.applyPhoneSettings() } .font(.footnote) } .padding(.horizontal, 4) } }}Custom settings
Section titled “Custom settings”Say your study needs a sensor threshold the package does not expose. You can add a custom setting by:
From Dart
Add the key in
AppleWatchDevice.toWatchSettings(). Keys the nativeAppleWatchSensor.Configdoes not recognise are forwarded to the watch verbatim.Read it on the watch
Pick it up in
apply(settings:)and apply it to the relevant sensor configuration.