Les pas d'une séance sont lus en dépliant les séries condensées par HealthKit

Apple, « Accessing condensed workout samples » : HealthKit fusionne les
échantillons d'une séance en séries (count > 1), supprime les originaux,
« while still preserving data resolution », et le détail se relit avec
HKQuantitySeriesSampleQuery. Depuis watchOS 27 (21/09/2026), les pas
d'une course arrivent en 4 à 7 tranches de 5 à 12 min ; le plugin Capgo
lit quantity.doubleValue sans regarder count.

getStepSamples(startDate, endDate) lit les pas de la fenêtre, déplie
chaque série et rend aussi les comptes (raw / series / expanded) : c'est
la preuve que le détail existe encore — ou qu'il n'existe plus. Lecture
seule. Syntaxe vérifiée (swiftc -parse), build Xcode à faire.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-09-22 19:55:48 +00:00
parent b15867273c
commit 8973a26e47

View File

@@ -28,6 +28,7 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
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), CAPPluginMethod(name: "getWorkoutDetails", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStepSamples", returnType: CAPPluginReturnPromise),
] ]
private lazy var store = HKHealthStore() private lazy var store = HKHealthStore()
@@ -396,6 +397,93 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
store.execute(wq) store.execute(wq)
} }
/// Pas d'une fenêtre de temps, séries condensées **dépliées**.
///
/// Apple, *Accessing condensed workout samples* : « HealthKit enumerates the
/// data associated with a workout and adds that data to one or more
/// quantity series samples. After saving all the data to series objects,
/// HealthKit deletes the originals » — « while still preserving data
/// resolution ». Un échantillon condensé a `count > 1`, et son détail se
/// lit avec `HKQuantitySeriesSampleQuery`. Depuis watchOS 27 (constaté le
/// 21/09/2026), les pas d'une course arrivent en 4 à 7 tranches de 5 à
/// 12 min au lieu de ~850 échantillons de 2,5 s ; le plugin Capgo lit
/// `quantity.doubleValue` sans regarder `count`. Cette méthode dit si le
/// détail existe encore, et le rend s'il existe. Lecture seule.
@objc func getStepSamples(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable(),
let stepType = HKObjectType.quantityType(forIdentifier: .stepCount) else {
call.resolve(["samples": [], "reason": "HealthKit unavailable"])
return
}
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let isoPlain = ISO8601DateFormatter()
func date(_ s: String?) -> Date? {
guard let s = s else { return nil }
return iso.date(from: s) ?? isoPlain.date(from: s)
}
guard let start = date(call.getString("startDate")),
let end = date(call.getString("endDate")), end > start else {
call.reject("startDate et endDate requis (ISO 8601), fin après début")
return
}
let window = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: stepType, predicate: window,
limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { [weak self] _, results, error in
guard let self = self else { return }
if let error = error {
call.reject("Step fetch failed: \(error.localizedDescription)")
return
}
let samples = (results as? [HKQuantitySample]) ?? []
let group = DispatchGroup()
let lock = NSLock()
var rows: [[String: Any]] = []
var nSeries = 0
var nExpanded = 0
for s in samples {
let src = s.sourceRevision.source
let base: [String: Any] = ["source": src.name, "sourceId": src.bundleIdentifier]
if s.count <= 1 {
var row = base
row["startDate"] = iso.string(from: s.startDate)
row["endDate"] = iso.string(from: s.endDate)
row["qty"] = s.quantity.doubleValue(for: .count())
lock.lock(); rows.append(row); lock.unlock()
continue
}
nSeries += 1
group.enter()
// Une série : ses quantités individuelles, chacune avec son
// intervalle. `done` marque le dernier appel du handler.
let inSeries = HKQuery.predicateForObject(with: s.uuid)
let detail = HKQuantitySeriesSampleQuery(quantityType: stepType,
predicate: inSeries) { _, quantity, interval, _, done, err in
if let quantity = quantity, let interval = interval {
var row = base
row["startDate"] = iso.string(from: interval.start)
row["endDate"] = iso.string(from: interval.end)
row["qty"] = quantity.doubleValue(for: .count())
row["fromSeries"] = true
lock.lock(); rows.append(row); nExpanded += 1; lock.unlock()
}
if done || err != nil { group.leave() }
}
self.store.execute(detail)
}
group.notify(queue: .global()) {
rows.sort { ($0["startDate"] as? String ?? "") < ($1["startDate"] as? String ?? "") }
NSLog("[CoachHealthRoute] pas : %d échantillons, %d séries, %d dépliés", samples.count, nSeries, nExpanded)
call.resolve(["samples": rows, "raw": samples.count, "series": nSeries, "expanded": nExpanded])
}
}
store.execute(query)
}
/// Assemble métadonnées + statistiques d'une séance. Les champs absents ne /// Assemble métadonnées + statistiques d'une séance. Les champs absents ne
/// sont pas émis : le backend distingue « pas mesuré » de « zéro ». /// sont pas émis : le backend distingue « pas mesuré » de « zéro ».
private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) { private func details(for workout: HKWorkout, completion: @escaping ([String: Any]) -> Void) {