diff --git a/docs/polar-ble-runbook-mac.md b/docs/polar-ble-runbook-mac.md index 7118c9d..f14e916 100644 --- a/docs/polar-ble-runbook-mac.md +++ b/docs/polar-ble-runbook-mac.md @@ -75,8 +75,19 @@ de la découvrir en radio. Retirer `dryRun`. Fermer l'app Polar Flow d'abord : **le canal BLE ne se partage pas**, et une synchro Flow en cours empêchera la connexion. -En cas d'échec, le message distingue les cas — montre introuvable, PsFTP -muet, requête refusée. Ce ne sont pas les mêmes causes. +En cas d'échec, le message distingue **trois causes opposées** — la distinction +a été ajoutée le 31/08 après un premier essai où un message unique ne permettait +pas de conclure : + +| Message | Cause probable | Geste | +|---|---|---| +| « aucun appareil Bluetooth détecté » | Bluetooth éteint, **autorisation refusée à coach**, montre hors de portée | Réglages → coach → Bluetooth | +| « N appareil(s) vu(s), M portant PsFTP mais sans réponse » | canal déjà occupé | fermer **complètement** Polar Flow | +| « N appareil(s) vu(s), aucun n'expose PsFTP » | la V3 n'annonce pas le service tant qu'elle est appairée à Flow | c'est l'inconnue de fond | + +⚠️ Le scan se fait par **fenêtres successives** de 4 s jusqu'au timeout, et non +en une passe : CoreBluetooth peut n'être pas encore `poweredOn` au premier +appel, et une fenêtre unique conclurait à tort qu'aucun appareil n'existe. ## L'heure de l'objectif — ce qu'on sait, et ce qu'on ne sait pas diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index 73bfef3..842e347 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -201,29 +201,42 @@ final class PolarPsFtpWriter { timeout: Double) async throws -> (BlePsFtpClient, BleDeviceSession) { let debut = Date() - let sessions = try await sessionsVues(listener, timeout: timeout) + var vues = 0 // appareils BLE aperçus, tous confondus + var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE + var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue - for session in sessions { - if Date().timeIntervalSince(debut) > timeout { break } - listener.openSessionDirect(session) - guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) - as? BlePsFtpClient else { - listener.closeSessionDirect(session) - continue - } - do { - try await client.waitPsFtpReady(true) - return (client, session) - } catch { - listener.closeSessionDirect(session) + // Le scan se fait par fenêtres successives, et non en une seule passe : + // CoreBluetooth peut n'être pas encore `poweredOn` au premier appel — le + // scan ne démarre alors jamais, et une fenêtre unique conclurait à tort + // qu'aucun appareil n'existe. Réessayer couvre ce démarrage. + while Date().timeIntervalSince(debut) < timeout { + let sessions = try await sessionsVues(listener, fenetre: 4) + vues = max(vues, sessions.count) + for session in sessions { + if Date().timeIntervalSince(debut) > timeout { break } + listener.openSessionDirect(session) + guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) + as? BlePsFtpClient else { + sansPsFtp += 1 + listener.closeSessionDirect(session) + continue + } + do { + try await client.waitPsFtpReady(true) + return (client, session) + } catch { + muettes += 1 + listener.closeSessionDirect(session) + } } } - throw PolarPftpError.watchNotFound(timeout) + throw PolarPftpError.watchNotFound(timeout, vues: vues, + sansPsFtp: sansPsFtp, muettes: muettes) } - /// Les appareils vus pendant la fenêtre de recherche, appairés compris. + /// Les appareils vus pendant une fenêtre de recherche, appairés compris. private func sessionsVues(_ listener: CBDeviceListenerImpl, - timeout: Double) async throws -> [BleDeviceSession] { + fenetre: Double) async throws -> [BleDeviceSession] { try await withCheckedThrowingContinuation { suite in var vues: [BleDeviceSession] = [] var rendu = false @@ -238,7 +251,7 @@ final class PolarPsFtpWriter { .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. - queue.asyncAfter(deadline: .now() + min(timeout, 10)) { rendre() } + queue.asyncAfter(deadline: .now() + fenetre) { rendre() } } } diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index 4538645..abedae7 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -99,19 +99,40 @@ public struct PftpStep: Equatable { public enum PolarPftpError: Error, CustomStringConvertible, Equatable { case malformed(String) - case watchNotFound(Double) + /// `vues` : appareils BLE aperçus · `sansPsFtp` : sans le service FEEE · + /// `muettes` : avec FEEE mais qui n'ont pas répondu. + case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int) case psftpUnavailable + /// ⚠️ 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 + /// 2026-08-31. Ne jamais fusionner ces branches. public var description: String { switch self { - case .malformed(let why): return why - case .watchNotFound(let seconds): - return "aucune montre Polar exposant PsFTP trouvée en \(Int(seconds)) s. " - + "Vérifier que la montre est allumée, à portée, et que l'app Polar " - + "Flow n'est pas en train de synchroniser — le canal BLE ne se " - + "partage pas." + case .malformed(let why): + return why case .psftpUnavailable: return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu" + + 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. " + + "Vérifier, dans l'ordre : le Bluetooth activé sur l'iPhone ; " + + "l'autorisation Bluetooth accordée à coach (Réglages → coach) ; " + + "la montre allumée et à portée." + + case .watchNotFound(let seconds, let vues, _, let muettes) where muettes > 0: + // Le service est là mais ne répond pas : signature d'un canal déjà pris. + return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans " + + "réponse en \(Int(seconds)) s. Le canal est probablement déjà " + + "occupé : fermer complètement l'app Polar Flow (elle synchronise " + + "en arrière-plan), puis réessayer." + + case .watchNotFound(let seconds, let vues, _, _): + // Des appareils, mais aucun ne porte le service. + return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun " + + "n'expose PsFTP. La Vantage n'annonce peut-être pas ce service " + + "tant qu'elle est appairée à Flow — c'est l'inconnue restante." } } } diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index 8a71260..cb43acd 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -118,3 +118,41 @@ final class PolarPftpStepTests: XCTestCase { XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put")) } } + +// MARK: - Les trois causes d'échec ne doivent pas se confondre +// +// Au premier essai Bluetooth du 2026-08-31, un message unique disait « aucune +// montre exposant PsFTP » — sans distinguer « rien vu du tout » (Bluetooth +// éteint, permission refusée) de « la montre est là mais ne répond pas » (canal +// occupé par Polar Flow). Ce sont des causes opposées et des gestes différents. + +final class PolarPftpErrorTests: XCTestCase { + + func testAucunAppareilVuPointeVersLIPhone() { + let e = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0) + XCTAssertTrue(e.description.contains("autorisation Bluetooth")) + XCTAssertFalse(e.description.contains("Polar Flow"), + "sans aucun appareil vu, accuser Flow envoie sur une fausse piste") + } + + func testUneMontreMuettePointeVersFlow() { + let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1) + XCTAssertTrue(e.description.contains("Polar Flow")) + XCTAssertTrue(e.description.contains("occupé")) + } + + func testDesAppareilsSansPsFtpNommentLInconnueRestante() { + let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0) + XCTAssertTrue(e.description.contains("n'expose PsFTP")) + XCTAssertTrue(e.description.contains("4 appareil")) + } + + func testLesTroisMessagesSontDistincts() { + let messages = Set([ + PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description, + PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1).description, + PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0).description, + ]) + XCTAssertEqual(messages.count, 3) + } +}