diff --git a/docs/polar-ble-runbook-mac.md b/docs/polar-ble-runbook-mac.md index 8c23d7a..6c5ac26 100644 --- a/docs/polar-ble-runbook-mac.md +++ b/docs/polar-ble-runbook-mac.md @@ -134,18 +134,29 @@ Ce qui a été écarté **sur les sources**, pour ne pas le refaire : - `addClient()` suffit à lancer le scan : `scanningNeeded()` rend vrai dès que `clientCount != 0`, aucune action « admin » n'est requise. -**L'hypothèse qui reste**, et que l'instrumentation ajoutée doit confirmer : -`search()` commence par `monitorBleState().filter { $0 == .poweredOn }`. Si cet -état n'arrive jamais, le publisher ne dit **rien** — ni valeur, ni complétion, -ni erreur — et le timeout conclut « aucun appareil » alors que le scan n'a -jamais démarré. Le message distingue désormais ce cas (« le SDK n'a jamais -signalé que le Bluetooth était prêt ») des autres. +**CAUSE RACINE TROUVÉE** — confirmée par l'instrumentation : le publisher de +`search()` n'émettait **rien du tout**, ni valeur, ni complétion, ni erreur. -⚠️ **Piste à explorer en premier à la reprise** : le SDK positionne -`powerStateObserver` et `deviceSessionStateObserver` sur son -`CBDeviceListenerImpl`, et passe `identifier: 0`. Notre code ne pose aucun -observateur et passe `identifier: 1`. Vérifier si `monitorBleState()` dépend -d'un de ces réglages. +`CBDeviceListenerImpl.manager` est une **`lazy var`** : le `CBCentralManager` +n'existe qu'au premier accès. + +```swift +fileprivate lazy var manager: SDKCBCentralManager = { … }() +``` + +Or `search()` commence par `monitorBleState().filter { $0 == .poweredOn }`, et +`monitorBleState()` se contente de rendre `bleStateSubject` — il ne touche +jamais `manager`. Les seuls accès du chemin de recherche +(`retrieveConnectedPeripherals`, `retrievePeripherals`) sont **à l'intérieur du +`flatMap`**, donc après le filtre. Le manager n'était donc jamais instancié, +`centralManagerDidUpdateState` jamais appelé, le sujet jamais alimenté : le +filtre bloquait pour toujours. + +**Correctif** : appeler `listener.blePowered()` — `return manager.state == +.poweredOn`, le seul accès public exécuté immédiatement — **après** s'être +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. ## Ce qui reste inconnu, et ne se lèvera qu'ici diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index c44eae3..b7e4d40 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -296,6 +296,30 @@ final class PolarPsFtpWriter { vues.append(session) }) .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. queue.asyncAfter(deadline: .now() + fenetre) { rendre() }