diff --git a/docs/polar-ble-runbook-mac.md b/docs/polar-ble-runbook-mac.md index f14e916..01527fd 100644 --- a/docs/polar-ble-runbook-mac.md +++ b/docs/polar-ble-runbook-mac.md @@ -42,8 +42,21 @@ Linux (`./tests-linux/run.sh`). ## 3. Permission Bluetooth -`NSBluetoothAlwaysUsageDescription` est déjà dans `Info.plist`, en français. -iOS demandera l'autorisation au premier scan. +`NSBluetoothAlwaysUsageDescription` est dans `Info.plist`, en français. + +⚠️ **La clé ne suffit pas à déclencher la demande.** iOS ne sollicite +l'autorisation qu'à la création d'un `CBCentralManager`. Or +`CBDeviceListenerImpl.search()` commence par +`monitorBleState().filter { $0 == .poweredOn }` : sans manager, cet état +n'arrive jamais, le flux n'émet rien, le scan ne démarre pas — et le timeout +conclut « aucun appareil ». Mesuré le 31/08 : 30 s de scan, aucune alerte, et +« Bluetooth » absent de Réglages → coach (seuls Position, Caméra, Mouvement, +Actualisation, Données cellulaires y figuraient). + +C'est pourquoi `PolarPsFtpWriter` commence par une `SondeBluetooth` : elle crée +le manager, attend son premier état, et traduit ce qu'il dit — autorisation +refusée, Bluetooth éteint, matériel absent. **Au premier lancement après ce +correctif, iOS affichera l'alerte d'autorisation : l'accepter.** ## 4. Le premier envoi — en `dryRun`, toujours diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index 842e347..64618bf 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -177,6 +177,23 @@ final class PolarPsFtpWriter { private var abonnements = Set() func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] { + // ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le + // dit. `CBDeviceListenerImpl.search()` commence par + // `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état + // n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on + // conclut « aucun appareil » au bout du timeout. + // + // Or l'état ne peut arriver que si un `CBCentralManager` existe — et + // c'est sa création qui déclenche l'alerte d'autorisation d'iOS. + // Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi + // que ce soit, et « Bluetooth » n'apparaissait même pas dans les + // réglages de l'app. L'autorisation n'avait jamais été sollicitée. + // + // 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 listener = CBDeviceListenerImpl( queue, clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }], @@ -265,3 +282,59 @@ final class PolarPsFtpWriter { } #endif + + +/// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas. +/// +/// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier +/// état. C'est cette instanciation qui provoque la demande d'autorisation iOS +/// — et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app. +private final class SondeBluetooth: NSObject, CBCentralManagerDelegate { + private var manager: CBCentralManager? + private var suite: CheckedContinuation? + private var repondu = false + + func attendreEtatUtilisable(timeout: Double) async throws { + let etat = await withCheckedContinuation { (c: CheckedContinuation) in + suite = c + // `showPowerAlert: false` : c'est nous qui expliquons, pas une + // alerte système au milieu d'un envoi. + manager = CBCentralManager(delegate: self, queue: nil, + options: [CBCentralManagerOptionShowPowerAlertKey: false]) + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in + self?.repondre(self?.manager?.state ?? .unknown) + } + } + switch etat { + case .poweredOn: + return + case .unauthorized: + throw PolarPftpError.bluetoothUnusable( + "coach n'a pas l'autorisation d'utiliser le Bluetooth. " + + "Réglages → coach → activer Bluetooth.") + case .poweredOff: + throw PolarPftpError.bluetoothUnusable( + "le Bluetooth est désactivé sur l'iPhone.") + case .unsupported: + throw PolarPftpError.bluetoothUnusable( + "cet appareil ne prend pas en charge le Bluetooth LE.") + default: + throw PolarPftpError.bluetoothUnusable( + "le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). " + + "Réessayer ; si ça persiste, redémarrer l'app.") + } + } + + private func repondre(_ etat: CBManagerState) { + guard !repondu else { return } + repondu = true + suite?.resume(returning: etat) + suite = nil + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // `.unknown` est l'état transitoire du démarrage : ne pas conclure + // dessus, le vrai état suit. + if central.state != .unknown { repondre(central.state) } + } +} diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index abedae7..72c0701 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -103,6 +103,9 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { /// `muettes` : avec FEEE mais qui n'ont pas répondu. case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int) case psftpUnavailable + /// 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) /// ⚠️ 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 @@ -113,6 +116,8 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { return why case .psftpUnavailable: return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu" + case .bluetoothUnusable(let why): + return why case .watchNotFound(let seconds, 0, _, _): // Rien du tout : le problème est en amont de la montre. diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index cb43acd..fd823aa 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -156,3 +156,27 @@ final class PolarPftpErrorTests: XCTestCase { XCTAssertEqual(messages.count, 3) } } + +// MARK: - « La radio ne marche pas » ≠ « la montre est introuvable » +// +// Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » — alors que +// la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation +// Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app. +// Un message parlant de la montre envoyait chercher au mauvais endroit. + +extension PolarPftpErrorTests { + + func testRadioInutilisableNeParlePasDeLaMontre() { + let e = PolarPftpError.bluetoothUnusable( + "coach n'a pas l'autorisation d'utiliser le Bluetooth.") + XCTAssertFalse(e.description.contains("montre")) + XCTAssertTrue(e.description.contains("autorisation")) + } + + func testRadioInutilisableEstDistinctDeMontreIntrouvable() { + let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description + let montre = PolarPftpError.watchNotFound(30, vues: 0, + sansPsFtp: 0, muettes: 0).description + XCTAssertNotEqual(radio, montre) + } +}