From 1389edb1f7b3f9325f7a5d249b5fc603e7dff8ea Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 31 Aug 2026 13:11:20 +0000 Subject: [PATCH] =?UTF-8?q?Cause=20racine=20:=20le=20CBCentralManager=20du?= =?UTF-8?q?=20SDK=20est=20lazy,=20et=20search()=20ne=20le=20r=C3=A9veille?= =?UTF-8?q?=20jamais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'instrumentation a tranché : le publisher de search() n'émettait RIEN — ni valeur, ni complétion, ni erreur. Ce n'était donc pas « aucun appareil », c'était « on n'a jamais cherché ». 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() 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 qui bloque. Le manager n'était jamais créé, centralManagerDidUpdateState jamais appelé, le sujet jamais alimenté : le filtre attendait un état que rien ne pouvait produire. `blePowered()` — `return manager.state == .poweredOn` — est le seul accès public exécuté immédiatement. On l'appelle donc pour instancier le manager, et on le fait 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. Cet ordre est écrit dans le code, c'est exactement ce qu'une relecture inverserait. Trois mesures ont conduit ici, aucune supposition : l'absence de ligne Bluetooth dans les réglages de l'app, puis 43 appareils en scan nu contre 0 par le SDK, puis le publisher muet. Chacune a éliminé une famille de causes. Co-Authored-By: Claude Opus 5 (1M context) --- docs/polar-ble-runbook-mac.md | 33 ++++++++++++++++++++++----------- ios/App/App/CoachPolarBLE.swift | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) 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() }