diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index b7e4d40..645df34 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -248,7 +248,16 @@ final class PolarPsFtpWriter { continue } do { - try await client.waitPsFtpReady(true) + // ⚠️ `waitPsFtpReady` n'a AUCUNE limite de temps. Si la + // montre ne finit pas la négociation — canal déjà pris, + // écran éteint, appairage en cours — l'attente ne rend + // jamais la main et l'envoi paraît figé, sans message. + // Constaté le 2026-08-31 : « envoi » tournant sans fin. + // On lui donne donc une échéance, et un dépassement compte + // comme une montre muette : c'est ce qu'il est. + try await Self.avecEcheance(seconds: 12) { + try await client.waitPsFtpReady(true) + } return (client, session) } catch { muettes += 1 @@ -326,12 +335,38 @@ final class PolarPsFtpWriter { } } + /// Court `operation` avec une échéance, et lève si elle est dépassée. + /// + /// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne + /// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit + /// dire pourquoi, pas tourner en silence. + static func avecEcheance(seconds: Double, + _ operation: @escaping @Sendable () async throws -> T) + async throws -> T { + try await withThrowingTaskGroup(of: T.self) { groupe in + groupe.addTask { try await operation() } + groupe.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw PolarPftpError.echeanceDepassee(seconds) + } + guard let premier = try await groupe.next() else { + throw PolarPftpError.echeanceDepassee(seconds) + } + groupe.cancelAll() + return premier + } + } + private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws { let entree = InputStream(data: etape.data) + // Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas + // doit échouer, pas figer l'app. // `write` rend un flux de progression ; on le consomme jusqu'au bout, // et l'absence d'erreur vaut acquittement — c'est l'équivalent BLE du // `05 00 00` observé en USB. - for try await _ in client.write(etape.header() as NSData, data: entree) {} + try await Self.avecEcheance(seconds: 20) { + for try await _ in client.write(etape.header() as NSData, data: entree) {} + } } } diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index 46efea4..99e8c0b 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -109,6 +109,8 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { /// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé /// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics /// opposés selon ce nombre. + /// Une opération du SDK n'a pas rendu la main dans le délai imparti. + case echeanceDepassee(Double) case sdkSilencieux(vusParCoreBluetooth: Int, publisherAParle: Bool = true, erreurSdk: String? = nil) @@ -125,6 +127,12 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable { case .bluetoothUnusable(let why): return why + 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 " + + "est bien fermée." + + case .sdkSilencieux(_, false, _): // Le publisher n'a JAMAIS rien émis : `search()` s'arrête sur // `monitorBleState().filter { $0 == .poweredOn }`. Le scan n'a donc diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index 4398f63..1227b9c 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -234,3 +234,27 @@ extension PolarPftpErrorTests { XCTAssertTrue(e.description.contains("intégration du SDK")) } } + +// MARK: - Un envoi qui n'aboutit pas doit le dire +// +// Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi +// est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne +// finissait pas la négociation. Une attente sans fin est pire qu'une erreur — +// elle ne dit rien et n'apprend rien. + +extension PolarPftpErrorTests { + + func testLEcheanceDitQuoiFaire() { + let e = PolarPftpError.echeanceDepassee(12) + XCTAssertTrue(e.description.contains("12")) + XCTAssertTrue(e.description.contains("écran")) + XCTAssertTrue(e.description.contains("Polar Flow")) + } + + func testLEcheanceNeSeConfondPasAvecUnScanVide() { + let echeance = PolarPftpError.echeanceDepassee(12).description + let vide = PolarPftpError.watchNotFound(30, vues: 0, + sansPsFtp: 0, muettes: 0).description + XCTAssertNotEqual(echeance, vide) + } +}