Le plugin santé expose les métriques que seule la montre mesure

getWorkoutDetails lit, pour une liste de séances : les métadonnées du
HKWorkout (dénivelé de l'altimètre barométrique, température, humidité,
indoor, METs) et la dynamique de course de watchOS 9+ (puissance, longueur
de foulée, oscillation verticale, temps de contact au sol, vitesse max).

@capgo/capacitor-health n'expose aucun de ces types, et le dénivelé
barométrique est plus juste que le cumul d'altitude GPS que le serveur
calculait faute de mieux. Sur une montre antérieure à la Series 6, les
champs de dynamique sont simplement absents de la réponse.

Le web appelle déjà la méthode derrière un test d'existence : elle ne
prendra effet qu'après ce build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-08 06:49:28 +00:00
parent 9790f0fa8a
commit 1c0e0320a9

View File

@@ -27,6 +27,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteWorkout", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getEffortScores", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
] ]
private lazy var store = HKHealthStore() private lazy var store = HKHealthStore()
@@ -59,6 +60,14 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
if #available(iOS 18.0, *) { if #available(iOS 18.0, *) {
types.insert(HKQuantityType(.workoutEffortScore)) types.insert(HKQuantityType(.workoutEffortScore))
} }
// Dynamique de course écrite par l'Apple Watch (Series 6+ / SE 2 / Ultra,
// watchOS 9+) : @capgo/capacitor-health n'expose aucun de ces types.
// Sur une montre plus ancienne, la permission est accordée mais aucune
// donnée n'existe getWorkoutDetails omet simplement les champs.
for id in Self.runningMetricIdentifiers {
types.insert(HKQuantityType(id))
}
if let steps = HKObjectType.quantityType(forIdentifier: .stepCount) { types.insert(steps) }
// Share workoutType requis pour deleteWorkout (HKHealthStore.delete // Share workoutType requis pour deleteWorkout (HKHealthStore.delete
// exige share auth). Read-only pour les autres types. // exige share auth). Read-only pour les autres types.
let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()] let shareTypes: Set<HKSampleType> = [HKObjectType.workoutType()]
@@ -304,6 +313,150 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
store.execute(workoutQuery) store.execute(workoutQuery)
} }
// Détail de séance : métadonnées Apple + dynamique de course
//
// Ce que @capgo/capacitor-health ne donne pas et qui n'existe nulle part
// ailleurs dans le pipeline :
// · métadonnées du HKWorkout dénivelé mesuré par l'altimètre
// barométrique de la Watch (bien plus juste que le cumul GPS),
// météo à la séance, indoor/outdoor, METs moyens ;
// · dynamique de course (watchOS 9+, Watch Series 6+ / SE 2 / Ultra)
// puissance, longueur de foulée, oscillation verticale, temps de
// contact au sol, vitesse instantanée moyenne/max.
//
// Usage JS :
// const r = await CoachHealthRoute.getWorkoutDetails({ workoutUUIDs: [...] });
// // r.details = [{ workout_id, elevation_ascended_m?, temperature_c?, }]
/// Types de dynamique de course, disponibles depuis iOS 16 / watchOS 9.
private static var runningMetricIdentifiers: [HKQuantityTypeIdentifier] {
guard #available(iOS 16.0, *) else { return [] }
return [
.runningSpeed, .runningPower, .runningStrideLength,
.runningVerticalOscillation, .runningGroundContactTime,
]
}
@objc func getWorkoutDetails(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["details": [], "reason": "HealthKit unavailable"])
return
}
let uuids = (call.getArray("workoutUUIDs") as? [String] ?? [])
.compactMap { UUID(uuidString: $0) }
guard !uuids.isEmpty else {
call.reject("workoutUUIDs requis (liste d'UUID strings)")
return
}
let pred = HKQuery.predicateForObjects(with: Set(uuids))
let wq = HKSampleQuery(
sampleType: HKObjectType.workoutType(),
predicate: pred,
limit: uuids.count,
sortDescriptors: nil
) { [weak self] _, results, err in
guard let self = self else { return }
if let err = err {
call.reject("Workout fetch failed: \(err.localizedDescription)")
return
}
let workouts = (results as? [HKWorkout]) ?? []
guard !workouts.isEmpty else {
call.resolve(["details": []])
return
}
let group = DispatchGroup()
let lock = NSLock()
var out: [[String: Any]] = []
for workout in workouts {
group.enter()
self.details(for: workout) { detail in
lock.lock()
out.append(detail)
lock.unlock()
group.leave()
}
}
group.notify(queue: .main) {
NSLog("[CoachHealthRoute] détails récupérés pour %d séance(s)", out.count)
call.resolve(["details": out])
}
}
store.execute(wq)
}
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {
var out: [String: Any] = ["workout_id": workout.uuid.uuidString]
let meta = workout.metadata ?? [:]
// Dénivelé : altimètre barométrique de la Watch, pas le GPS.
if let ascended = meta[HKMetadataKeyElevationAscended] as? HKQuantity {
out["elevation_ascended_m"] = ascended.doubleValue(for: .meter()).rounded()
}
if let descended = meta["HKElevationDescended"] as? HKQuantity {
out["elevation_descended_m"] = descended.doubleValue(for: .meter()).rounded()
}
if let temp = meta[HKMetadataKeyWeatherTemperature] as? HKQuantity {
out["temperature_c"] = (temp.doubleValue(for: .degreeCelsius()) * 10).rounded() / 10
}
if let hum = meta[HKMetadataKeyWeatherHumidity] as? HKQuantity {
out["humidity_pct"] = hum.doubleValue(for: .percent()).rounded()
}
if let indoor = meta[HKMetadataKeyIndoorWorkout] as? Bool {
out["is_indoor"] = indoor
}
if let mets = meta[HKMetadataKeyAverageMETs] as? HKQuantity {
let unit = HKUnit.kilocalorie().unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .hour()))
out["average_mets"] = (mets.doubleValue(for: unit) * 10).rounded() / 10
}
// Statistiques agrégées sur la fenêtre exacte de la séance.
let samplePredicate = HKQuery.predicateForObjects(from: workout)
var requests: [(key: String, id: HKQuantityTypeIdentifier, unit: HKUnit, option: HKStatisticsOptions, scale: Double)] = []
if #available(iOS 16.0, *) {
let perMinute = HKUnit.count().unitDivided(by: .minute())
_ = perMinute // cadence dérivée côté serveur depuis stepCount
requests += [
("running_speed_avg_ms", .runningSpeed, .meter().unitDivided(by: .second()), .discreteAverage, 100),
("running_speed_max_ms", .runningSpeed, .meter().unitDivided(by: .second()), .discreteMax, 100),
("running_power_avg_w", .runningPower, .watt(), .discreteAverage, 1),
("running_power_max_w", .runningPower, .watt(), .discreteMax, 1),
("stride_length_m", .runningStrideLength, .meter(), .discreteAverage, 100),
("vertical_oscillation_cm", .runningVerticalOscillation, .meterUnit(with: .centi), .discreteAverage, 10),
("ground_contact_ms", .runningGroundContactTime, .secondUnit(with: .milli), .discreteAverage, 1),
]
}
requests.append(("steps", .stepCount, .count(), .cumulativeSum, 1))
let group = DispatchGroup()
let lock = NSLock()
for req in requests {
guard let type = HKObjectType.quantityType(forIdentifier: req.id) else { continue }
group.enter()
let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: samplePredicate,
options: req.option) { _, stats, _ in
defer { group.leave() }
let quantity: HKQuantity?
switch req.option {
case .discreteAverage: quantity = stats?.averageQuantity()
case .discreteMax: quantity = stats?.maximumQuantity()
default: quantity = stats?.sumQuantity()
}
guard let value = quantity?.doubleValue(for: req.unit), value > 0 else { return }
lock.lock()
out[req.key] = (value * req.scale).rounded() / req.scale
lock.unlock()
}
self.store.execute(q)
}
group.notify(queue: .global()) { completion(out) }
}
@objc func deleteWorkout(_ call: CAPPluginCall) { @objc func deleteWorkout(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else { guard HKHealthStore.isHealthDataAvailable() else {
call.reject("HealthKit unavailable") call.reject("HealthKit unavailable")