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:
@@ -75,8 +75,19 @@ de la découvrir en radio.
|
|||||||
Retirer `dryRun`. Fermer l'app Polar Flow d'abord : **le canal BLE ne se
|
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.
|
partage pas**, et une synchro Flow en cours empêchera la connexion.
|
||||||
|
|
||||||
En cas d'échec, le message distingue les cas — montre introuvable, PsFTP
|
En cas d'échec, le message distingue **trois causes opposées** — la distinction
|
||||||
muet, requête refusée. Ce ne sont pas les mêmes causes.
|
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
|
## L'heure de l'objectif — ce qu'on sait, et ce qu'on ne sait pas
|
||||||
|
|
||||||
|
|||||||
@@ -201,29 +201,42 @@ final class PolarPsFtpWriter {
|
|||||||
timeout: Double) async throws
|
timeout: Double) async throws
|
||||||
-> (BlePsFtpClient, BleDeviceSession) {
|
-> (BlePsFtpClient, BleDeviceSession) {
|
||||||
let debut = Date()
|
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 {
|
// Le scan se fait par fenêtres successives, et non en une seule passe :
|
||||||
if Date().timeIntervalSince(debut) > timeout { break }
|
// CoreBluetooth peut n'être pas encore `poweredOn` au premier appel — le
|
||||||
listener.openSessionDirect(session)
|
// scan ne démarre alors jamais, et une fenêtre unique conclurait à tort
|
||||||
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
|
// qu'aucun appareil n'existe. Réessayer couvre ce démarrage.
|
||||||
as? BlePsFtpClient else {
|
while Date().timeIntervalSince(debut) < timeout {
|
||||||
listener.closeSessionDirect(session)
|
let sessions = try await sessionsVues(listener, fenetre: 4)
|
||||||
continue
|
vues = max(vues, sessions.count)
|
||||||
}
|
for session in sessions {
|
||||||
do {
|
if Date().timeIntervalSince(debut) > timeout { break }
|
||||||
try await client.waitPsFtpReady(true)
|
listener.openSessionDirect(session)
|
||||||
return (client, session)
|
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
|
||||||
} catch {
|
as? BlePsFtpClient else {
|
||||||
listener.closeSessionDirect(session)
|
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,
|
private func sessionsVues(_ listener: CBDeviceListenerImpl,
|
||||||
timeout: Double) async throws -> [BleDeviceSession] {
|
fenetre: Double) async throws -> [BleDeviceSession] {
|
||||||
try await withCheckedThrowingContinuation { suite in
|
try await withCheckedThrowingContinuation { suite in
|
||||||
var vues: [BleDeviceSession] = []
|
var vues: [BleDeviceSession] = []
|
||||||
var rendu = false
|
var rendu = false
|
||||||
@@ -238,7 +251,7 @@ final class PolarPsFtpWriter {
|
|||||||
.store(in: &abonnements)
|
.store(in: &abonnements)
|
||||||
// La recherche ne se termine pas d'elle-même : on lui donne une
|
// 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.
|
// 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 {
|
public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
||||||
case malformed(String)
|
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
|
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 {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .malformed(let why): return why
|
case .malformed(let why):
|
||||||
case .watchNotFound(let seconds):
|
return why
|
||||||
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 .psftpUnavailable:
|
case .psftpUnavailable:
|
||||||
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
|
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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,3 +118,41 @@ final class PolarPftpStepTests: XCTestCase {
|
|||||||
XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put"))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user