diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index 11bf9f9..3f47323 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -206,27 +206,18 @@ final class PolarPsFtpWriter { clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }], identifier: 1) - // ⚠️ FILTRER, SINON ON OUVRE UNE SESSION SUR TOUT CE QUI PASSE. + // ⚠️ NE PAS FILTRER À L'AVEUGLE — on trie après avoir vu. // - // Sans `scanPreFilter`, le listener remonte TOUS les appareils BLE - // alentour — 43 mesurés le 2026-08-31. Le code tentait alors une - // session sur chacun à tour de rôle, dont l'Apple Watch : + // Une version précédente posait `scanPreFilter` sur « polarDeviceId non + // vide OU nom contenant polar ». Si la Vantage ne s'annonce pas ainsi, + // elle était rejetée par NOUS, aucune session n'était créée, et le + // publisher de `search()` ne disait rien — ce que le message + // interprétait à tort comme « le scan n'a pas démarré ». // - // API MISUSE: can only accept commands while in the - // connected state - // - // À 12 s d'échéance par appareil, l'envoi paraissait ne jamais finir. - // Le SDK officiel pose exactement ce filtre (`deviceFilter` dans - // `PolarBleApiImpl`) : ne pas le poser était l'omission. - // - // `polarDeviceId` est vide pour un appareil qui n'est pas un Polar ; le - // repli sur le nom couvre le cas où l'identifiant n'est pas encore - // décodé au moment du filtrage. - listener.scanPreFilter = { contenu in - !contenu.polarDeviceId.isEmpty - || contenu.name.lowercased().contains("polar") - } + // On laisse donc tout remonter et on trie dans la boucle : les appareils + // qui ressemblent à du Polar d'abord, et on n'ouvre de session que sur + // ceux-là. Si aucun ne ressemble, on RAPPORTE ce qu'on a vu au lieu de + // conclure. // ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout // abonnement. @@ -296,6 +287,8 @@ final class PolarPsFtpWriter { -> (BlePsFtpClient, BleDeviceSession) { let debut = Date() var vues = 0 // appareils BLE aperçus, tous confondus + var candidats = 0 // ceux qui ressemblent à un Polar + var noms: [String] = [] var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue @@ -311,8 +304,15 @@ final class PolarPsFtpWriter { // L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été // sondé jusqu'à devenir vrai. do { - let sessions = try await sessionsVues(listener, fenetre: timeout) - vues = sessions.count + let toutes = try await sessionsVues(listener, fenetre: timeout) + vues = toutes.count + noms = toutes.map { Self.etiquette($0) } + + // Ouvrir une session coûte plusieurs secondes : on ne les tente que + // sur ce qui ressemble à un Polar, sinon 43 appareils feraient des + // minutes d'attente (mesuré, avec des erreurs sur l'Apple Watch). + let sessions = toutes.filter { Self.ressembleAPolar($0) } + candidats = sessions.count for session in sessions { // Marge au-delà du scan : ouvrir une session prend du temps, et // couper ici ferait échouer la seule montre trouvée. @@ -342,6 +342,13 @@ final class PolarPsFtpWriter { } } } + if vues > 0 && candidats == 0 { + // Le scan marche, mais rien ne ressemble à un Polar. Dire ce qu'on + // a vu : c'est la seule façon de savoir sous quel nom la montre + // s'annonce — ou si elle ne s'annonce pas du tout. + throw PolarPftpError.aucunPolarParmi(vues: vues, + exemples: Array(noms.prefix(8))) + } 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. @@ -390,6 +397,25 @@ final class PolarPsFtpWriter { } } + /// Ce qu'on peut dire d'un appareil vu, pour l'afficher. + static func etiquette(_ session: BleDeviceSession) -> String { + let c = session.advertisementContent + let nom = c.name.isEmpty ? "(sans nom)" : c.name + return c.polarDeviceId.isEmpty ? nom : "\(nom) [\(c.polarDeviceId)]" + } + + /// Vrai si l'appareil a une chance d'être une montre Polar. + /// + /// ⚠️ Volontairement LARGE : mieux vaut tenter une session de trop que + /// d'exclure la montre et conclure qu'elle n'existe pas. + static func ressembleAPolar(_ session: BleDeviceSession) -> Bool { + let c = session.advertisementContent + if !c.polarDeviceId.isEmpty { return true } + if !c.polarDeviceType.isEmpty { return true } + let nom = c.name.lowercased() + return nom.contains("polar") || nom.contains("vantage") || nom.contains("grit") + } + /// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout /// du délai. /// diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index 99e8c0b..cc986b0 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -111,6 +111,9 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { /// opposés selon ce nombre. /// Une opération du SDK n'a pas rendu la main dans le délai imparti. case echeanceDepassee(Double) + /// Le scan a vu des appareils, mais aucun ne ressemble à un Polar. Les + /// exemples servent à voir sous quel nom la montre s'annonce réellement. + case aucunPolarParmi(vues: Int, exemples: [String]) case sdkSilencieux(vusParCoreBluetooth: Int, publisherAParle: Bool = true, erreurSdk: String? = nil) @@ -127,6 +130,14 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { case .bluetoothUnusable(let why): return why + case .aucunPolarParmi(let vues, let exemples): + return "\(vues) appareil(s) Bluetooth vus, aucun ne ressemble à une " + + "montre Polar. Vus : \(exemples.joined(separator: ", ")). " + + "Si la montre est dans cette liste sous un autre nom, c'est le " + + "tri qui est trop strict ; si elle n'y est pas, elle ne " + + "s'annonce pas — probablement parce qu'elle est déjà liée à " + + "Polar Flow." + case .echeanceDepassee(let seconds): return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son " + "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow " @@ -134,13 +145,15 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { case .sdkSilencieux(_, false, _): - // Le publisher n'a JAMAIS rien émis : `search()` s'arrête sur - // `monitorBleState().filter { $0 == .poweredOn }`. Le scan n'a donc - // pas démarré — ce n'est pas « aucun appareil », c'est « on n'a - // jamais cherché ». - return "le SDK Polar n'a jamais signalé que le Bluetooth était prêt : " - + "son scan n'a pas démarré. Ce n'est pas la montre. " - + "Piste : le listener du SDK n'obtient pas l'état poweredOn." + // ⚠️ Ne PAS conclure « le scan n'a pas démarré » : le publisher de + // `search()` reste également muet quand le scan tourne mais ne + // découvre rien — `scanSubject` n'émet que sur découverte, et le + // `prepend(knownSessions)` d'une liste vide n'émet pas. Une version + // précédente affirmait le contraire et a fait chercher au mauvais + // endroit pendant trois itérations. + return "le SDK Polar n'a remonté aucun appareil : soit son scan n'a " + + "pas démarré, soit il tourne sans rien découvrir. L'état " + + "Bluetooth, lui, a bien été atteint avant le scan." case .sdkSilencieux(_, _, .some(let erreur)): return "le SDK Polar a répondu par une erreur : \(erreur)" diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index 1227b9c..adc00ca 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -215,10 +215,15 @@ extension PolarPftpErrorTests { extension PolarPftpErrorTests { func testPublisherMuetNAccusePasLaMontre() { + // ⚠️ Assoupli le 2026-08-31 : le message affirmait « le scan n'a pas + // démarré », ce qui était une conclusion de trop — le publisher se tait + // aussi quand le scan tourne sans rien découvrir. Ce test vérifie + // désormais qu'on n'accuse ni la montre, ni une cause unique. let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, publisherAParle: false) - XCTAssertTrue(e.description.contains("n'a pas démarré")) - XCTAssertTrue(e.description.contains("Ce n'est pas la montre")) + XCTAssertTrue(e.description.contains("aucun appareil")) + XCTAssertFalse(e.description.contains("Ce n'est pas la montre"), + "ne rien affirmer sur la montre : on n'en sait rien ici") } func testUneErreurDuSdkEstRapporteeTelleQuelle() { @@ -258,3 +263,30 @@ extension PolarPftpErrorTests { XCTAssertNotEqual(echeance, vide) } } + +// MARK: - Ne pas conclure plus que ce qu'on a mesuré +// +// Le publisher de `search()` reste muet dans DEUX cas : le scan n'a pas démarré, +// ou il tourne sans rien découvrir (`scanSubject` n'émet que sur découverte). +// Un message affirmant le premier a fait chercher au mauvais endroit pendant +// trois itérations du 2026-08-31. + +extension PolarPftpErrorTests { + + func testLeSilenceDuSdkNAffirmePasQueLeScanEstArrete() { + let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, + publisherAParle: false) + XCTAssertTrue(e.description.contains("soit"), + "le message doit énoncer les deux possibilités") + XCTAssertFalse(e.description.contains("n'a jamais signalé")) + } + + func testAucunPolarListeCeQuiAEteVu() { + let e = PolarPftpError.aucunPolarParmi( + vues: 43, exemples: ["Apple Watch de Sylvain", "(sans nom)"]) + XCTAssertTrue(e.description.contains("43")) + XCTAssertTrue(e.description.contains("Apple Watch de Sylvain")) + XCTAssertTrue(e.description.contains("Polar Flow"), + "l'hypothèse du lien exclusif doit être énoncée") + } +}