diff --git a/docs/polar-ble-runbook-mac.md b/docs/polar-ble-runbook-mac.md index 2ba81be..72b97a9 100644 --- a/docs/polar-ble-runbook-mac.md +++ b/docs/polar-ble-runbook-mac.md @@ -182,10 +182,18 @@ piège suivant. depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation de la lazy soit terminée, la propriété se réentre. -⇒ **Séquence correcte** : créer le listener, poser `scanPreFilter`, réveiller la -lazy en sondant `blePowered()` jusqu'à ce qu'elle réponde vrai — hors de tout -abonnement — et seulement ensuite appeler `search()`, une fois, sur toute la -durée. Un abonnement par tranche relancerait le cycle +**3. `blePowered()` et `search()` ne lisent pas la même chose.** `blePowered()` +rend `manager.state == .poweredOn` — l'état de CoreBluetooth. `search()` filtre +sur `bleStateSubject`, qui n'est alimenté que par `centralManagerDidUpdateState`. +Le manager peut donc être allumé sans que le délégué ait publié quoi que ce +soit : le sujet reste à `.unknown` et le filtre bloque. Attendre `blePowered()` +ne prouve rien sur ce que `search()` verra. + +⇒ **Séquence correcte** : créer le listener, poser `scanPreFilter`, appeler +`blePowered()` **une fois** (son seul rôle : instancier la lazy pour que le +délégué puisse tourner), puis attendre sur **`monitorBleState()`** — public, et +c'est la source que `search()` lit — jusqu'à `.poweredOn`. Seulement ensuite, +`search()`, une fois, sur toute la durée. Un abonnement par tranche relancerait le cycle `addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que ce soit. diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index ce1d0a4..11bf9f9 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -252,16 +252,26 @@ final class PolarPsFtpWriter { // réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser // l'état se poser. // - // On attend donc que `blePowered()` réponde vrai — ce qui signifie que - // le délégué a tourné et que `.poweredOn` est dans le sujet — avant - // d'appeler `search()`. - let pret = await Self.attendre(seconds: min(timeout, 10)) { - listener.blePowered() - } - if !pret { + // ⚠️ ET SURTOUT : attendre le SUJET, pas l'état du manager. + // + // `blePowered()` lit `manager.state` — l'état de CoreBluetooth. Mais + // `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par + // `centralManagerDidUpdateState`. Le manager peut donc être allumé + // (`blePowered()` vrai) sans que le délégué ait encore publié quoi que + // ce soit : le sujet reste à `.unknown`, le filtre bloque, et le + // publisher se tait. C'est exactement ce qui a été mesuré le + // 2026-08-31, y compris après avoir attendu `blePowered()`. + // + // On attend donc sur `monitorBleState()`, qui est public et qui EST la + // source lue par `search()`. `blePowered()` ne sert plus qu'à une + // chose : instancier la lazy pour que le délégué puisse tourner. + _ = listener.blePowered() + let etat = await listener.premierEtatPret(timeout: min(timeout, 12)) + if !etat { throw PolarPftpError.bluetoothUnusable( - "le listener du SDK Polar n'a pas atteint l'état « prêt ». " - + "Bluetooth actif mais inutilisable par le SDK.") + "le listener du SDK Polar n'a jamais publié l'état « prêt » " + + "(son délégué n'a pas été appelé). Bluetooth actif côté " + + "système, mais inutilisable par le SDK.") } let (client, session) = try await trouverClient(listener, timeout: timeout, @@ -383,6 +393,9 @@ final class PolarPsFtpWriter { /// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout /// du délai. /// + /// ⚠️ Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état + /// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute. + /// /// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et /// c'est justement cette lecture qui instancie le CBCentralManager. Le /// premier appel réveille, les suivants observent. @@ -509,3 +522,48 @@ private final class SondeBluetooth: NSObject, CBCentralManagerDelegate { if central.state != .unknown { repondre(central.state) } } } + + +extension CBDeviceListenerImpl { + /// Vrai dès que le listener PUBLIE l'état `.poweredOn` — c'est-à-dire dès + /// que `search()` pourra franchir son filtre. + /// + /// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement + /// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les + /// suivantes. On attend la première qui vaut `.poweredOn`. + /// Porte son échéance elle-même plutôt que d'être enveloppée : le listener + /// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait + /// à la concurrence stricte de Swift 6. + func premierEtatPret(timeout: Double) async -> Bool { + await withCheckedContinuation { suite in + let boite = BoiteJeton() + boite.jeton = monitorBleState() + .sink(receiveCompletion: { _ in boite.rendre(false, suite) }, + receiveValue: { etat in + if etat == .poweredOn { boite.rendre(true, suite) } + }) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + boite.rendre(false, suite) + } + } + } +} + +/// Garde l'abonnement en vie et garantit une reprise unique de la continuation +/// — la reprendre deux fois est un crash, et il y a ici deux chemins de sortie +/// concurrents : l'état publié et l'échéance. +private final class BoiteJeton: @unchecked Sendable { + var jeton: AnyCancellable? + private var rendu = false + private let verrou = NSLock() + + func rendre(_ valeur: Bool, _ suite: CheckedContinuation) { + verrou.lock() + defer { verrou.unlock() } + guard !rendu else { return } + rendu = true + suite.resume(returning: valeur) + jeton?.cancel() + jeton = nil + } +}