diff --git a/docs/polar-ble-runbook-mac.md b/docs/polar-ble-runbook-mac.md index 8360dce..2ba81be 100644 --- a/docs/polar-ble-runbook-mac.md +++ b/docs/polar-ble-runbook-mac.md @@ -166,13 +166,28 @@ abonné à `search()`. L'ordre est critique : si `bleStateSubject` est un `PassthroughSubject`, un état émis avant l'abonnement serait perdu et on retomberait sur le même silence. -## 🔴 ÉTAT AU SOIR DU 31/08 : bloqué, et pourquoi il faut changer d'approche +## Le démarrage du SDK — deux détails qui décident de tout -Malgré le réveil du manager, le filtre Polar et l'abonnement unique, le -publisher de `search()` **reste muet** : `bleStateSubject` n'émet jamais -`.poweredOn`. Le manager du SDK existe pourtant — l'avertissement CoreBluetooth -`SDKCBCentralManager … has no restore identifier` ne peut apparaître que s'il a -été instancié. +Lus dans les sources, après plusieurs essais infructueux le 31/08 : + +**1. `bleStateSubject` est un `CurrentValueSubject(.unknown)`**, +pas un PassthroughSubject. Il **rejoue sa valeur à chaque abonnement** : il est +donc inutile d'être abonné au moment de l'émission. Un correctif intermédiaire +réveillait le manager *après* s'être abonné « pour ne pas rater l'événement » — +raisonnement juste pour un PassthroughSubject, faux ici, et qui exposait au +piège suivant. + +**2. `centralManagerDidUpdateState` fait +`BleState(rawValue: self.manager.state.rawValue)`** — il accède à la `lazy var` +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 +`addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que +ce soit. **Ce qui a été corrigé et vérifié en chemin** (ne pas refaire) : diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index cf13bd3..ce1d0a4 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -228,6 +228,42 @@ final class PolarPsFtpWriter { || contenu.name.lowercased().contains("polar") } + // ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout + // abonnement. + // + // `CBDeviceListenerImpl.manager` est une `lazy var` : le + // CBCentralManager n'existe qu'au premier accès, et `search()` ne le + // touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on + // attend un état que rien ne peut produire. + // + // Deux détails, tirés des sources, qui décident du succès : + // + // 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à + // `.unknown`. Il REJOUE sa valeur à chaque abonnement — inutile donc + // d'être abonné au moment de l'émission. Une version précédente + // réveillait après l'abonnement « pour ne pas rater l'événement » : + // raisonnement de PassthroughSubject, faux ici, et qui exposait au + // piège suivant. + // + // 2. `centralManagerDidUpdateState` fait + // `BleState(rawValue: self.manager.state.rawValue)` — il accède à la + // lazy 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. 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 { + throw PolarPftpError.bluetoothUnusable( + "le listener du SDK Polar n'a pas atteint l'état « prêt ». " + + "Bluetooth actif mais inutilisable par le SDK.") + } + let (client, session) = try await trouverClient(listener, timeout: timeout, sonde: sonde) defer { listener.closeSessionDirect(session) } @@ -255,16 +291,15 @@ final class PolarPsFtpWriter { // ⚠️ UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE — pas des fenêtres. // - // Le découpage en cycles de 4 s se privait de l'unique émission d'état : - // `bleStateSubject` ne publie que sur CHANGEMENT. Le premier cycle - // réveillait le manager et recevait `.poweredOn` ; aux cycles suivants - // l'état ne changeait plus, donc plus rien n'était émis et le publisher - // restait muet — mesuré le 2026-08-31, y compris après avoir réglé le - // réveil du manager. + // Non pas parce qu'une émission serait ratée — `bleStateSubject` est un + // CurrentValueSubject, il rejoue sa valeur à chaque abonnement — mais + // parce que chaque abonnement relance le cycle `addClient()` / + // `removeClient()` du scanner. Redémarrer un scan toutes les 4 s + // l'empêche de découvrir quoi que ce soit, et la découverte BLE demande + // plusieurs secondes ininterrompues. // - // Une seule fenêtre, de la durée du timeout, supprime le problème : on - // reçoit l'émission là où elle a lieu, et le scan tourne sans être - // relancé. + // L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été + // sondé jusqu'à devenir vrai. do { let sessions = try await sessionsVues(listener, fenetre: timeout) vues = sessions.count @@ -338,28 +373,6 @@ final class PolarPsFtpWriter { }) .store(in: &abonnements) - // ⚠️ RÉVEIL DE LA PROPRIÉTÉ LAZY — SANS ÇA, RIEN NE SE PASSE. - // - // `CBDeviceListenerImpl.manager` est une `lazy var` : le - // CBCentralManager n'existe qu'au premier accès. Or `search()` - // commence par `monitorBleState().filter { $0 == .poweredOn }`, et - // `monitorBleState()` ne fait que rendre `bleStateSubject` — il ne - // touche jamais `manager`. Les seuls accès du chemin de recherche - // sont À L'INTÉRIEUR du `flatMap`, donc après le filtre. - // - // Résultat mesuré le 2026-08-31 : le manager n'était jamais créé, - // `centralManagerDidUpdateState` jamais appelé, le sujet n'émettait - // jamais, et le publisher restait muet — ni valeur, ni fin, ni - // erreur. 43 appareils vus par un scan CoreBluetooth nu, 0 par le - // SDK. - // - // `blePowered()` lit `manager.state` : c'est le seul accès public - // exécuté immédiatement, donc celui qui instancie le manager. - // - // ⚠️ L'ORDRE EST CRITIQUE : réveiller APRÈS s'être abonné. Si - // `bleStateSubject` est un PassthroughSubject, un état émis avant - // l'abonnement serait perdu, et on retomberait sur le même silence. - _ = listener.blePowered() // La recherche ne se termine pas d'elle-même : on lui donne une // fenêtre, puis on travaille avec ce qu'on a vu. @@ -367,6 +380,21 @@ final class PolarPsFtpWriter { } } + /// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout + /// du délai. + /// + /// 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. + static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool { + let echeance = Date().addingTimeInterval(seconds) + while Date() < echeance { + if condition() { return true } + try? await Task.sleep(nanoseconds: 200_000_000) + } + return condition() + } + /// Court `operation` avec une échéance, et lève si elle est dépassée. /// /// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne