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:
Sylvain Bettinelli
2026-08-31 12:47:28 +00:00
parent 0110368e17
commit 179528d563
4 changed files with 110 additions and 27 deletions

View File

@@ -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

View File

@@ -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() }
}
}

View File

@@ -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."
}
}
}

View File

@@ -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)
}
}