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")) + } +}