diff --git a/docs/polar-ble-runbook-mac.md b/docs/polar-ble-runbook-mac.md index 01527fd..8c23d7a 100644 --- a/docs/polar-ble-runbook-mac.md +++ b/docs/polar-ble-runbook-mac.md @@ -119,10 +119,38 @@ réussi : écrire un objectif daté 18 h et regarder s'il apparaît immédiateme - S'il n'apparaît qu'à l'approche → il faut écrire à l'heure du départ. Sans conséquence pratique : l'envoi se fait de toute façon juste avant de sortir. +## 🔴 Où ça bloque au 31/08 : le scan du SDK ne démarre pas + +Mesuré, pas supposé : **43 appareils vus par un scan CoreBluetooth nu, 0 remonté +par le SDK Polar.** La radio, l'autorisation et la montre sont donc hors de +cause — le problème est dans notre usage du SDK. + +Ce qui a été écarté **sur les sources**, pour ne pas le refaire : + +- `servicesToScanFor` à `nil` : c'est aussi ce que fait `PolarBleApiImpl`. +- `scanPreFilter` à `nil` : **accepte** les appareils. La condition est + `if self.session(peripheral) == nil, let filter = self.scanPreFilter` — sans + filtre, le `if let` échoue et le rejet est sauté. +- `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. + +⚠️ **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. + ## Ce qui reste inconnu, et ne se lèvera qu'ici 1. **La V3 accepte-t-elle une connexion BLE tierce** alors qu'elle est appairée - à l'app Flow ? Jamais testé. C'est le risque principal. + à l'app Flow ? Toujours pas testé — on n'a pas encore atteint la montre. 2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) », pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé, et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index d045b1c..c44eae3 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -175,6 +175,12 @@ public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin { final class PolarPsFtpWriter { private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble") private var abonnements = Set() + /// Le publisher de `search()` a-t-il émis quoi que ce soit — valeur, fin ou + /// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis + /// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun + /// appareil », c'est « on n'a jamais cherché ». + private var publisherAParle = false + private var derniereErreurSdk: String? func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] { // ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le @@ -254,7 +260,9 @@ final class PolarPsFtpWriter { // Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ? // Un scan nu tranche, et son résultat part dans le message. let bruts = await sonde.compterAppareils(pendant: 6) - throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts) + throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts, + publisherAParle: publisherAParle, + erreurSdk: derniereErreurSdk) } throw PolarPftpError.watchNotFound(timeout, vues: vues, sansPsFtp: sansPsFtp, muettes: muettes) @@ -272,8 +280,21 @@ final class PolarPsFtpWriter { suite.resume(returning: vues) } listener.search(nil, identifiers: nil, fetchKnownDevices: true) - .sink(receiveCompletion: { _ in rendre() }, - receiveValue: { vues.append($0) }) + .sink(receiveCompletion: { [weak self] fin in + // Distinguer « le publisher a terminé » de « il n'a + // jamais rien dit » : dans le second cas, `flatMap` + // n'a pas été atteint, donc `monitorBleState()` n'a + // jamais émis `.poweredOn` — le scan n'a pas démarré. + if case .failure(let e) = fin { + self?.derniereErreurSdk = String(describing: e) + } + self?.publisherAParle = true + rendre() + }, + receiveValue: { [weak self] session in + self?.publisherAParle = true + vues.append(session) + }) .store(in: &abonnements) // 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. diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index fc953a2..46efea4 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -109,7 +109,9 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { /// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé /// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics /// opposés selon ce nombre. - case sdkSilencieux(vusParCoreBluetooth: Int) + case sdkSilencieux(vusParCoreBluetooth: Int, + publisherAParle: Bool = true, + erreurSdk: String? = nil) /// ⚠️ Un échec de connexion a plusieurs causes OPPOSÉES, et un message /// unique les confond — c'est ce qui s'est produit au premier essai du @@ -123,13 +125,25 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { case .bluetoothUnusable(let why): return why - case .sdkSilencieux(0): + case .sdkSilencieux(_, false, _): + // Le publisher n'a JAMAIS rien émis : `search()` s'arrête sur + // `monitorBleState().filter { $0 == .poweredOn }`. Le scan n'a donc + // pas démarré — ce n'est pas « aucun appareil », c'est « on n'a + // jamais cherché ». + return "le SDK Polar n'a jamais signalé que le Bluetooth était prêt : " + + "son scan n'a pas démarré. Ce n'est pas la montre. " + + "Piste : le listener du SDK n'obtient pas l'état poweredOn." + + case .sdkSilencieux(_, _, .some(let erreur)): + return "le SDK Polar a répondu par une erreur : \(erreur)" + + case .sdkSilencieux(0, _, _): return "aucun appareil Bluetooth alentour, même en scan direct. " + "La radio fonctionne mais ne voit rien : montre éteinte, hors " + "de portée, ou déjà connectée à un autre appareil de façon " + "exclusive." - case .sdkSilencieux(let bruts): + case .sdkSilencieux(let bruts, _, _): return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK " + "Polar n'en remonte aucun. La radio va bien : le problème est " + "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone." diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index 67565fa..4398f63 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -203,3 +203,34 @@ extension PolarPftpErrorTests { XCTAssertTrue(e.description.contains("La radio va bien")) } } + +// MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé » +// +// `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`. +// Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni +// erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a +// jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le +// SDK. + +extension PolarPftpErrorTests { + + func testPublisherMuetNAccusePasLaMontre() { + let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, + publisherAParle: false) + XCTAssertTrue(e.description.contains("n'a pas démarré")) + XCTAssertTrue(e.description.contains("Ce n'est pas la montre")) + } + + func testUneErreurDuSdkEstRapporteeTelleQuelle() { + let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, + publisherAParle: true, + erreurSdk: "bleNotReady") + XCTAssertTrue(e.description.contains("bleNotReady")) + } + + func testPublisherActifSansSessionAccuseLIntegration() { + let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, + publisherAParle: true) + XCTAssertTrue(e.description.contains("intégration du SDK")) + } +}