visit
I have played tennis for 25 years, and I always try to improve my game – whether by watching some coaching videos or trying new apps like Swing Vision. At the latest WWDC, I noticed a session about introducing higher-frequency sensor data. The presenter was showing how such data can be used to analyze a golf swing. I thought that tennis could also be a perfect use case for leveraging the new sensors, so I set out to build an app that would collect raw motion data and share it as a file for further analysis.
In this part 1, I will show how to build such an app and share sample code. In the following parts, I will present data from a real tennis session and try to turn it into something meaningful.
Starting from WatchOS 10.0, there is a new class, , that can give 800-Hz accelerometer and 200-Hz gyroscope updates, which are 8x and 2x, respectively, more frequent than in the previous versions.
First of all, we need a shared Codable
model that would be able to hold the sensor data. Codable
will let us encode and pass this data between the devices or store it on disk if needed:
struct AccelerometerSnapshot: Codable {
let timestamp: TimeInterval
let accelerationX: Double
let accelerationY: Double
let accelerationZ: Double
}
struct GyroscopeSnapshot: Codable {
let timestamp: TimeInterval
let rotationX: Double
let rotationY: Double
let rotationZ: Double
}
struct TennisMotionData: Codable {
let accelerometerSnapshots: [AccelerometerSnapshot]
let gyroscopeSnapshots: [GyroscopeSnapshot]
}
struct TennisDataChunk: Codable {
let date: Date
let data: TennisMotionData
// init from non-codable CM classes
}
1. Set up CMBatchedSensorManager
let sensorManager = CMBatchedSensorManager()
2. Set up HealthKit
and workout-related logic
let healthStore = HKHealthStore()
var workoutSession: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
// ...
// sensor data can be collected only during workout
let configuration = HKWorkoutConfiguration()
configuration.activityType = .tennis
configuration.locationType = .outdoor
do {
workoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = workoutSession?.associatedWorkoutBuilder()
} catch {
return
}
builder?.dataSource = HKLiveWorkoutDataSource(
healthStore: healthStore,
workoutConfiguration: configuration
)
3. Set up WatchConnectivity
session to pass the recorded data to iPhone
let session = WCSession.default
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
4. Connect all those pieces together
When the user starts data collection, we need to start the activity and begin data collection:let startDate = Date()
workoutSession?.startActivity(with: startDate)
builder?.beginCollection(withStart: startDate) { (success, error) in
if success {
self.state = .active
}
Task {
do {
for try await data in CMBatchedSensorManager().accelerometerUpdates() {
let dataChunk = TennisDataChunk(date: Date(), accelerometerData: data, gyroscopeData: [])
sendToiPhone(dataChunk: dataChunk)
}
} catch let error as NSError {
print("\(error)")
}
}
Task {
do {
for try await data in CMBatchedSensorManager().deviceMotionUpdates() {
let dataChunk = TennisDataChunk(date: Date(), accelerometerData: [], gyroscopeData: data)
sendToiPhone(dataChunk: dataChunk)
}
} catch let error as NSError {
print("\(error)")
}
}
}
With this code above, every chunk of fresh data from the sensors will be immediately sent to the iPhone. If we don't send it right away, the data will become too large to fit into a message payload that can be sent quickly with session.sendMessage
. Otherwise, we would have to pass files. The simplest and quickest way is still to send a message with a Dictionary
:
private func sendToiPhone(dataChunk: TennisDataChunk) {
let dict: [String : Any] = ["data": dataChunk.encodeIt()]
session.sendMessage(dict, replyHandler: { reply in
print("Got reply from iPhone")
}, errorHandler: { error in
print("Failed to send data to iPhone: \(error)")
})
}
1. Receive and decode the motion data chunks to display them in a table view
extension ViewController: WCSessionDelegate {
// ...
func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
guard let data: Data = message["data"] as? Data else { return }
let chunk = TennisDataChunk.decodeIt(data)
DispatchQueue.main.async {
self.tennisDataChunks.append(chunk)
}
}
}
2. Be able to export those chunks as a file – for example, a .csv and then share it using UIActivityViewController
to send it via AirDrop or save it to iCloud drive:
private func exportAccelerometerData() {
var result = tennisDataChunks.reduce("") { partialResult, chunk in
if !chunk.data.accelerometerSnapshots.isEmpty {
return partialResult + "\n\(chunk.createAcceletometerDataCSV())"
}
}
shareStringAsFile(string: result, filename: "tennis-acceleration-\(Date()).csv")
}
// same for gyroscope ^
// creating file and sharing it with share sheer
private func shareStringAsFile(string: String, filename: String) {
if string.isEmpty {
return
}
do {
let filename = "\(self.getDocumentsDirectory())/\(filename)"
let fileURL = URL(fileURLWithPath: filename)
try string.write(to: fileURL, atomically: true, encoding: .utf8)
let vc = UIActivityViewController(activityItems: [fileURL], applicationActivities: [])
self.present(vc, animated: true)
} catch {
print("cannot write file")
}
}
private func getDocumentsDirectory() -> String {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let documentsDirectory = paths[0]
return documentsDirectory
}