From 14133efba9cc06f8324cd4f3e907962041518e49 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 31 Aug 2026 13:01:28 +0000 Subject: [PATCH] =?UTF-8?q?Quand=20le=20SDK=20ne=20voit=20rien,=20demander?= =?UTF-8?q?=20=C3=A0=20CoreBluetooth=20ce=20que=20LUI=20voit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autorisation Bluetooth accordée, alerte acceptée — et toujours zéro appareil. Or un scan BLE en environnement normal voit toujours quelque chose : le problème n'est donc pas la montre. Restait à savoir s'il venait de la radio ou de notre usage du SDK, et rien dans le message ne permettait de trancher. J'ai vérifié dans les sources ce qui pouvait faire taire le scan côté SDK, et écarté deux hypothèses plutôt que de les supposer : `servicesToScanFor` est nil chez eux aussi, et un `scanPreFilter` nil ACCEPTE les appareils découverts (`if …, let filter = self.scanPreFilter` — la condition échoue, le rejet est sauté). Ni l'un ni l'autre n'explique le silence. Donc on mesure. Quand le SDK ne remonte aucune session, la sonde relance un scan CoreBluetooth NU, sans le SDK, et le nombre part dans le message : - 0 appareil en scan direct → la radio va bien mais ne voit rien : montre éteinte, hors de portée, ou connectée ailleurs de façon exclusive. - N appareils en scan direct → la radio va bien, le problème est dans notre intégration du SDK, ni dans la montre ni dans l'iPhone. Ce sont deux chantiers opposés, et deviner lequel a déjà coûté trois allers-retours aujourd'hui. Deux tests verrouillent la distinction. 77 tests au vert sur tests-linux. Co-Authored-By: Claude Opus 5 (1M context) --- ios/App/App/CoachPolarBLE.swift | 37 +++++++++++++++++-- ios/App/App/PolarPftpStep.swift | 16 ++++++++ .../CoachModelTests/PolarPftpStepTests.swift | 23 ++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index 64618bf..d045b1c 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -192,14 +192,16 @@ final class PolarPsFtpWriter { // On crée donc le manager nous-mêmes, on attend son premier état, et on // traduit ce qu'il dit — au lieu de laisser un silence passer pour une // absence de montre. - try await SondeBluetooth().attendreEtatUtilisable(timeout: min(timeout, 15)) + let sonde = SondeBluetooth() + try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15)) let listener = CBDeviceListenerImpl( queue, clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }], identifier: 1) - let (client, session) = try await trouverClient(listener, timeout: timeout) + let (client, session) = try await trouverClient(listener, timeout: timeout, + sonde: sonde) defer { listener.closeSessionDirect(session) } var journal: [String] = [] @@ -215,7 +217,8 @@ final class PolarPsFtpWriter { } private func trouverClient(_ listener: CBDeviceListenerImpl, - timeout: Double) async throws + timeout: Double, + sonde: SondeBluetooth) async throws -> (BlePsFtpClient, BleDeviceSession) { let debut = Date() var vues = 0 // appareils BLE aperçus, tous confondus @@ -247,6 +250,12 @@ final class PolarPsFtpWriter { } } } + if vues == 0 { + // 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.watchNotFound(timeout, vues: vues, sansPsFtp: sansPsFtp, muettes: muettes) } @@ -293,6 +302,7 @@ private final class SondeBluetooth: NSObject, CBCentralManagerDelegate { private var manager: CBCentralManager? private var suite: CheckedContinuation? private var repondu = false + private var vus = Set() func attendreEtatUtilisable(timeout: Double) async throws { let etat = await withCheckedContinuation { (c: CheckedContinuation) in @@ -325,6 +335,27 @@ private final class SondeBluetooth: NSObject, CBCentralManagerDelegate { } } + /// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK. + /// + /// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit + /// des appareils et que le SDK n'en remonte aucun, le problème est dans + /// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou + /// l'environnement. Sans cette mesure on ne peut que deviner — ce qui a + /// coûté trois allers-retours le 2026-08-31. + func compterAppareils(pendant: Double) async -> Int { + vus.removeAll() + manager?.scanForPeripherals(withServices: nil, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000)) + manager?.stopScan() + return vus.count + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + vus.insert(peripheral.identifier) + } + private func repondre(_ etat: CBManagerState) { guard !repondu else { return } repondu = true diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index 72c0701..fc953a2 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -106,6 +106,10 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { /// La radio elle-même est inutilisable : autorisation, Bluetooth éteint, /// matériel. Distinct de `watchNotFound` — ici on n'a même pas pu chercher. case bluetoothUnusable(String) + /// 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) /// ⚠️ 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 @@ -119,6 +123,18 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { case .bluetoothUnusable(let why): return why + 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): + 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." + + case .watchNotFound(let seconds, 0, _, _): // Rien du tout : le problème est en amont de la montre. return "aucun appareil Bluetooth détecté en \(Int(seconds)) s. " diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index fd823aa..67565fa 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -180,3 +180,26 @@ extension PolarPftpErrorTests { XCTAssertNotEqual(radio, montre) } } + +// MARK: - Radio muette ou SDK muet : ce n'est pas le même diagnostic +// +// Le 2026-08-31, le SDK ne remontait aucune session et rien ne disait si +// CoreBluetooth lui-même voyait quelque chose. Sans cette mesure, impossible de +// savoir s'il fallait chercher du côté de la montre ou de notre code. + +extension PolarPftpErrorTests { + + func testAucunAppareilEnScanDirectAccuseLEnvironnement() { + let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 0) + XCTAssertTrue(e.description.contains("hors\u{00A0}de portée") + || e.description.contains("hors de portée")) + XCTAssertFalse(e.description.contains("intégration")) + } + + func testDesAppareilsEnScanDirectAccusentLeSDK() { + let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 12) + XCTAssertTrue(e.description.contains("intégration du SDK")) + XCTAssertTrue(e.description.contains("12")) + XCTAssertTrue(e.description.contains("La radio va bien")) + } +}