Skip to content

Commit e68140b

Browse files
brtkwrclaude
andcommitted
refactor: single /metrics endpoint, remove /route and /heart_rate
Everything is served via /workouts/{index}/metrics — route and HR are included in the response. Log now shows both metric and GPS counts. Updated README, mock server, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0b9c8f6 commit e68140b

4 files changed

Lines changed: 71 additions & 198 deletions

File tree

HealthKitExporter/HTTPServer.swift

Lines changed: 3 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -128,21 +128,11 @@ final class HTTPServer: ObservableObject {
128128
return
129129
}
130130

131-
if let index = parseWorkoutSubpath(path: path, suffix: "heart_rate") {
132-
await handleGetHeartRate(connection: connection, index: index)
133-
return
134-
}
135-
136131
if let index = parseWorkoutSubpath(path: path, suffix: "metrics") {
137132
await handleGetMetrics(connection: connection, index: index)
138133
return
139134
}
140135

141-
if let index = parseWorkoutSubpath(path: path, suffix: "route") {
142-
await handleGetRoute(connection: connection, index: index)
143-
return
144-
}
145-
146136
sendResponse(connection: connection, status: 404, body: "{\"error\": \"Not found\"}")
147137
}
148138

@@ -187,42 +177,6 @@ final class HTTPServer: ObservableObject {
187177
}
188178
}
189179

190-
@MainActor
191-
private func handleGetHeartRate(connection: NWConnection, index: Int) async {
192-
guard let manager = healthKitManager else {
193-
sendResponse(
194-
connection: connection, status: 500,
195-
body: "{\"error\": \"HealthKit not available\"}")
196-
return
197-
}
198-
199-
do {
200-
if cachedWorkouts == nil {
201-
cachedWorkouts = try await manager.fetchWorkouts()
202-
}
203-
204-
guard let workouts = cachedWorkouts, index >= 0, index < workouts.count else {
205-
sendResponse(
206-
connection: connection, status: 404,
207-
body: "{\"error\": \"Workout not found at index \(index)\"}")
208-
return
209-
}
210-
211-
let workout = workouts[index]
212-
let hrType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
213-
let hrUnit = HKUnit.count().unitDivided(by: .minute())
214-
let hrData = try await manager.fetchQuantitySeries(
215-
for: workout, quantityType: hrType, unit: hrUnit)
216-
log("/workouts/\(index)/heart_rate", status: 200, detail: "\(hrData.count) points")
217-
sendJSONResponse(connection: connection, object: hrData)
218-
} catch {
219-
log("/workouts/\(index)/heart_rate", status: 500, detail: error.localizedDescription)
220-
sendResponse(
221-
connection: connection, status: 500,
222-
body: "{\"error\": \"\(error.localizedDescription)\"}")
223-
}
224-
}
225-
226180
@MainActor
227181
private func handleGetMetrics(connection: NWConnection, index: Int) async {
228182
guard let manager = healthKitManager else {
@@ -245,8 +199,9 @@ final class HTTPServer: ObservableObject {
245199
}
246200

247201
let metrics = try await manager.fetchAllMetrics(for: workouts[index])
248-
let totalPoints = (metrics.values.compactMap { ($0 as? [[String: Any]])?.count }.reduce(0, +))
249-
log("/workouts/\(index)/metrics", status: 200, detail: "\(totalPoints) points")
202+
let routeCount = (metrics["route"] as? [[String: Any]])?.count ?? 0
203+
let metricCount = metrics.values.compactMap { ($0 as? [[String: Any]])?.count }.reduce(0, +) - routeCount
204+
log("/workouts/\(index)/metrics", status: 200, detail: "\(metricCount) metrics, \(routeCount) GPS")
250205
sendJSONResponse(connection: connection, object: metrics)
251206
} catch {
252207
log("/workouts/\(index)/metrics", status: 500, detail: error.localizedDescription)
@@ -256,37 +211,6 @@ final class HTTPServer: ObservableObject {
256211
}
257212
}
258213

259-
@MainActor
260-
private func handleGetRoute(connection: NWConnection, index: Int) async {
261-
guard let manager = healthKitManager else {
262-
sendResponse(
263-
connection: connection, status: 500,
264-
body: "{\"error\": \"HealthKit not available\"}")
265-
return
266-
}
267-
268-
do {
269-
if cachedWorkouts == nil {
270-
cachedWorkouts = try await manager.fetchWorkouts()
271-
}
272-
273-
guard let workouts = cachedWorkouts, index >= 0, index < workouts.count else {
274-
sendResponse(
275-
connection: connection, status: 404,
276-
body: "{\"error\": \"Workout not found at index \(index)\"}")
277-
return
278-
}
279-
280-
let route = try await manager.fetchRoute(for: workouts[index])
281-
log("/workouts/\(index)/route", status: 200, detail: "\(route.count) points")
282-
sendJSONResponse(connection: connection, object: route)
283-
} catch {
284-
sendResponse(
285-
connection: connection, status: 500,
286-
body: "{\"error\": \"\(error.localizedDescription)\"}")
287-
}
288-
}
289-
290214
// MARK: - Response helpers
291215

292216
private func sendJSONResponse(connection: NWConnection, object: Any) {

README.md

Lines changed: 67 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,153 +1,118 @@
1-
# Apple Health to Garmin Converter
1+
# Apple Health to Garmin
22

3-
Convert Apple Watch workouts from Apple Health export to **FIT** or **TCX** format for importing to Garmin Connect, Strava, TrainingPeaks, and other fitness platforms.
3+
Convert Apple Watch workouts to Garmin's FIT format with full-resolution heart rate, running power, stride length, vertical oscillation, and ground contact time.
44

5-
## Features
5+
Apple's "Export All Health Data" aggregates workout heart rate into low-resolution chunks. This tool bypasses that by reading HealthKit directly on the iPhone via a small sideloaded app, then converting the full-resolution data to FIT files for Garmin Connect.
66

7-
- Converts to FIT (recommended) or TCX format
8-
- Preserves per-second heart rate, GPS routes, and workout statistics
9-
- FIT output includes running power, stride length, vertical oscillation, and ground contact time
10-
- Organises workouts by year/month
11-
- Supports running, walking, cycling, hiking, swimming, and other workout types
7+
## How it works
128

13-
## Requirements
14-
15-
- Python 3.11+
16-
- [uv](https://docs.astral.sh/uv/) (for FIT format — manages dependencies automatically)
17-
- Apple Health export data
18-
19-
## How to Export Apple Health Data
9+
1. **iOS app** serves workout data from HealthKit over a local HTTP server
10+
2. **Fetch script** pulls all workouts (metrics + GPS) from the phone to your Mac
11+
3. **Converter** produces FIT files ready for Garmin Connect
2012

21-
1. Open the **Apple Health** app on your iPhone
22-
2. Tap your **profile picture** (top right corner)
23-
3. Scroll down and tap **"Export All Health Data"**
24-
4. Tap **"Export"** to confirm
25-
5. **AirDrop or email** the ZIP to your Mac
26-
6. Extract the ZIP — you'll get a folder containing:
27-
- `export.xml` — main health data including workout statistics and per-second metrics
28-
- `workout-routes/` — GPX files with GPS trackpoints for each workout
29-
30-
## Usage
13+
## Requirements
3114

32-
### FIT format (recommended)
15+
- iPhone with Apple Health data
16+
- Mac with [Xcode](https://apps.apple.com/app/xcode/id497799835) (for sideloading the iOS app)
17+
- [uv](https://docs.astral.sh/uv/) (Python package manager)
3318

34-
FIT is Garmin's native binary format and preserves the most data — heart rate, running power, stride length, vertical oscillation, and ground contact time.
19+
## Quick start
3520

3621
```bash
3722
git clone https://github.com/brtkwr/apple-to-garmin.git
3823
cd apple-to-garmin
39-
uv run convert_to_fit.py /path/to/apple_health_export
4024
```
4125

42-
Filter by activity type:
43-
```bash
44-
uv run convert_to_fit.py /path/to/export --activity running
45-
```
26+
### 1. Install the iOS app
27+
28+
1. Open `HealthKitExporter.xcodeproj` in Xcode
29+
2. Set your development team in **Signing & Capabilities**
30+
3. Connect your iPhone via USB and hit **Run**
31+
4. On your iPhone: **Settings > General > VPN & Device Management** → trust the developer certificate
32+
33+
### 2. Fetch workouts from the phone
34+
35+
Open the app on your iPhone, tap **Start**, then from your Mac:
4636

47-
Specify output directory:
4837
```bash
49-
uv run convert_to_fit.py /path/to/export --output /path/to/fit/files
38+
python3 fetch_healthkit.py <iphone-ip>
5039
```
5140

52-
### TCX format (zero dependencies)
41+
This pulls all Apple Watch workouts with full-resolution metrics and GPS routes into `healthkit_export/`. Keep the phone screen on while fetching — HealthKit data is inaccessible when the screen is locked.
5342

54-
TCX is an XML-based format that works everywhere but only supports heart rate, GPS, altitude, distance, and calories. No running dynamics.
43+
### 3. Convert to FIT
5544

5645
```bash
57-
python3 convert_to_tcx.py /path/to/apple_health_export
46+
uv run convert_healthkit_to_fit.py healthkit_export
5847
```
5948

60-
## Output Structure
61-
62-
```text
63-
fit_files/ # or tcx_files/
64-
├── 2024/
65-
│ ├── 01/
66-
│ │ ├── 2024-01-02_183050_Running.fit
67-
│ │ └── 2024-01-06_090528_Running.fit
68-
│ └── 02/
69-
│ └── 2024-02-20_182954_Running.fit
70-
└── 2025/
71-
└── ...
72-
```
49+
FIT files are written to `fit_files/`, organised by year and month.
7350

74-
TCX output also includes a `no_heart_rate/` subfolder for workouts without HR data.
51+
Filter by activity type:
7552

76-
## What Gets Converted
53+
```bash
54+
uv run convert_healthkit_to_fit.py healthkit_export --activity running
55+
```
7756

78-
| Data | FIT | TCX |
79-
|------|-----|-----|
80-
| GPS coordinates |||
81-
| Heart rate (per-second) |||
82-
| Distance |||
83-
| Calories |||
84-
| Altitude |||
85-
| Running power |||
86-
| Stride length |||
87-
| Vertical oscillation |||
88-
| Ground contact time |||
89-
| Running speed |||
57+
### 4. Import to Garmin Connect
9058

91-
## Heart Rate Data
59+
1. Go to [Garmin Connect](https://connect.garmin.com)
60+
2. Click **"+"** → Import Data
61+
3. Upload your FIT files (can select multiple)
9262

93-
### From the XML export
63+
## What gets exported
9464

95-
Apple Health exports contain `HKQuantityTypeIdentifierHeartRate` records in `export.xml`. The FIT converter emits each HR reading at its original timestamp as a separate record alongside the GPS stream — no interpolation, highest resolution available from the export.
65+
| Data | Included |
66+
| -------------------- | -------- |
67+
| GPS coordinates | Yes |
68+
| Heart rate | Yes |
69+
| Distance | Yes |
70+
| Calories | Yes |
71+
| Altitude | Yes |
72+
| Running power | Yes |
73+
| Stride length | Yes |
74+
| Vertical oscillation | Yes |
75+
| Ground contact time | Yes |
76+
| Running speed | Yes |
9677

97-
### Known limitation: low-resolution HR during workouts
78+
## Why not use Apple's XML export?
9879

99-
Apple's "Export All Health Data" feature aggregates workout heart rate into ~15 minute chunks. A 65-minute run might only have 23 HR records in the export, while apps like Strava (which sync via HealthKit's API directly) show a smooth per-second curve. This is a [known issue](https://discussions.apple.com/thread/253843222).
80+
Apple's "Export All Health Data" stores workout heart rate as aggregated records spanning ~15 minute windows. A 65-minute run might only have 23 HR data points in the export. The same workout viewed on Strava (which reads HealthKit directly) shows 513 data points.
10081

101-
The per-second data exists on the iPhone via `HKQuantitySeriesSampleQuery`, but Apple doesn't include it in the XML export.
82+
This is a [known limitation](https://discussions.apple.com/thread/253843222) of Apple's XML export. The full-resolution data exists on the phone via `HKQuantitySeriesSampleQuery` — this tool accesses it.
10283

103-
### HealthKit Exporter iOS app
84+
## API endpoints
10485

105-
The `HealthKitExporter/` directory contains a SwiftUI iOS app that reads full-resolution HR and running dynamics directly from HealthKit and serves them as JSON over a local HTTP server. This bypasses the XML export limitation.
86+
The iOS app serves JSON on port 8080:
10687

107-
To use it:
88+
| Endpoint | Description |
89+
| ------------------------------- | ------------------------------------- |
90+
| `GET /workouts` | List all workouts with metadata |
91+
| `GET /workouts/{index}/metrics` | All metrics + GPS route for a workout |
10892

109-
1. Open `HealthKitExporter.xcodeproj` in Xcode
110-
2. Set your development team in Signing & Capabilities
111-
3. Build and run on your iPhone
112-
4. The app shows the device IP and port — hit the endpoints from your Mac:
93+
## Development
11394

114-
```bash
115-
# List all workouts
116-
curl http://<iphone-ip>:8080/workouts
95+
### Mock server
11796

118-
# Get per-second HR for a specific workout
119-
curl http://<iphone-ip>:8080/workouts/0/heart_rate
97+
For local development and testing without a phone:
12098

121-
# Get all metrics (HR, power, speed, stride, VO, GCT)
122-
curl http://<iphone-ip>:8080/workouts/0/metrics
99+
```bash
100+
python3 mock_server.py
123101
```
124102

125-
Requires Xcode and a free Apple Developer account (app expires after 7 days, re-install from Xcode as needed).
126-
127-
## Importing to Garmin Connect
103+
Serves sample workouts on `http://localhost:8080` with the same API as the iOS app.
128104

129-
1. Go to [Garmin Connect](https://connect.garmin.com)
130-
2. Click the **"+"** button → Import Data
131-
3. Upload your FIT or TCX files (can select multiple)
132-
4. Wait for processing — workouts will appear in your timeline
133-
134-
## Testing
105+
### Tests
135106

136107
```bash
137108
uv run pytest -v
138109
```
139110

140-
Tests run automatically on GitHub Actions for Python 3.11-3.13.
141-
142-
## Troubleshooting
111+
44 tests, 91% coverage. Tests use the mock server — no phone required.
143112

144-
**"Found 0 Apple Watch workouts"**
145-
- Make sure you've exported from the Apple Health app (not Apple Watch app)
146-
- Verify the export folder contains `export.xml` and `workout-routes/`
113+
### CI
147114

148-
**"No heart rate data"**
149-
- Early Apple Watch workouts may not have recorded heart rate
150-
- TCX converter saves these separately in `no_heart_rate/`
115+
Tests run on GitHub Actions for Python 3.11–3.14. Coverage is reported on PRs. iOS build can be triggered manually via workflow dispatch.
151116

152117
## License
153118

mock_server.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,6 @@ def do_GET(self):
168168
self._send_json({"error": f"Workout not found at index {idx}"}, 404)
169169
return
170170

171-
match = re.match(r"^/workouts/(\d+)/route$", self.path)
172-
if match:
173-
idx = int(match.group(1))
174-
if 0 <= idx < len(self.workouts or []):
175-
metrics = generate_sample_metrics(self.workouts[idx])
176-
self._send_json(metrics.get("route", []))
177-
else:
178-
self._send_json({"error": f"Workout not found at index {idx}"}, 404)
179-
return
180-
181171
self._send_json({"error": "Not found"}, 404)
182172

183173
def _send_json(self, data, status=200):
@@ -221,7 +211,7 @@ def main():
221211
print("Endpoints:")
222212
print(" GET /workouts")
223213
print(" GET /workouts/{index}/metrics")
224-
print(" GET /workouts/{index}/route")
214+
print(" GET /workouts/{index}/heart_rate")
225215
try:
226216
server.serve_forever()
227217
except KeyboardInterrupt:

test_fetch_healthkit.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,6 @@ def test_metrics_endpoint_invalid_index(self):
125125
self._fetch("/workouts/999/metrics")
126126
self.assertEqual(ctx.exception.code, 404)
127127

128-
def test_route_endpoint(self):
129-
data = self._fetch("/workouts/0/route")
130-
self.assertIsInstance(data, list)
131-
self.assertGreater(len(data), 0)
132-
self.assertIn("latitude", data[0])
133-
134128
def test_404_for_unknown_path(self):
135129
import urllib.error
136130
with self.assertRaises(urllib.error.HTTPError) as ctx:

0 commit comments

Comments
 (0)