From 6deb61e86e1b1e1438de2ab465831e8435d3a56a Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Mon, 31 Aug 2026 12:57:02 +0000 Subject: [PATCH] =?UTF-8?q?iOS=20n'avait=20jamais=20demand=C3=A9=20l'autor?= =?UTF-8?q?isation=20Bluetooth,=20et=20le=20scan=20attendait=20un=20=C3=A9?= =?UTF-8?q?tat=20qui=20n'arrivait=20pas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « aucun appareil Bluetooth détecté » après 30 s de scan. La cause n'était pas la montre : Réglages → coach ne contenait même pas de ligne Bluetooth. L'alerte d'autorisation n'avait jamais été présentée. La raison est dans le SDK. `CBDeviceListenerImpl.search()` commence par `monitorBleState().filter { $0 == .poweredOn }` — tant que cet état n'arrive pas, le flux n'émet rien et le scan ne démarre jamais. Or l'état ne peut venir que d'un CBCentralManager, et c'est sa création qui déclenche la demande d'autorisation. Aucun manager n'existant, on attendait un signal que rien ne pouvait produire, puis on concluait « aucun appareil » au timeout. Avoir la clé NSBluetoothAlwaysUsageDescription dans Info.plist ne suffit pas : elle fournit le texte de l'alerte, elle ne la provoque pas. `SondeBluetooth` crée donc le manager, attend son premier état non-`.unknown` (l'état transitoire du démarrage, sur lequel il ne faut pas conclure), et traduit ce qu'il dit : autorisation refusée, Bluetooth éteint, matériel absent. Le cas d'erreur est distinct de watchNotFound — on n'a même pas pu chercher, et parler de la montre envoyait chercher au mauvais endroit. Deux tests verrouillent cette distinction. Au premier lancement après ce correctif, iOS présentera l'alerte : il faut l'accepter. Ensuite seulement on saura si la Vantage accepte une connexion tierce, qui reste la question de fond. 75 tests au vert sur tests-linux. Co-Authored-By: Claude Opus 5 (1M context) --- docs/polar-ble-runbook-mac.md | 17 ++++- ios/App/App/CoachPolarBLE.swift | 73 +++++++++++++++++++ ios/App/App/PolarPftpStep.swift | 5 ++ .../CoachModelTests/PolarPftpStepTests.swift | 24 ++++++ 4 files changed, 117 insertions(+), 2 deletions(-) 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) + } +}