Trois causes opposées disaient la même chose : le premier essai BLE n'a rien appris
Sylvain a lancé l'envoi Bluetooth et obtenu « aucune montre Polar exposant PsFTP ». Ce message ne permet pas de conclure : il couvre à la fois « aucun appareil vu » (Bluetooth éteint, autorisation refusée à l'app, montre hors de portée) et « la montre est là mais ne répond pas » (canal déjà pris par Polar Flow). Ce sont des causes opposées et des gestes différents. Un essai qui n'apprend rien est un essai perdu, et celui-là demandait de brancher une montre. L'erreur porte désormais ce qui a été observé — appareils vus, combien sans le service FEEE, combien avec mais muets — et rend trois messages distincts, chacun nommant le geste correspondant. Quatre tests verrouillent la distinction, dont celui qui interdit d'accuser Polar Flow quand rien n'a été vu : ce serait envoyer sur une fausse piste. Le scan devient aussi répétitif, par fenêtres de 4 s jusqu'au timeout, au lieu d'une passe unique de 10 s. CoreBluetooth peut n'être pas encore poweredOn au premier appel : le scan ne démarre alors jamais et une fenêtre unique conclut à tort qu'aucun appareil n'existe. C'est une cause plausible du premier échec, et elle n'était pas couverte. 73 tests au vert sur tests-linux. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user