Mon filtre rejetait peut-être la montre, et mon message accusait le scan

Le dépôt était bien à jour : c'est moi qui avais mal lu mon propre code. Le
message incriminé EST atteignable par la nouvelle version — l'état Bluetooth est
publié, on passe au scan, et le publisher se tait quand même.

Deux erreurs à moi, pas une du SDK.

D'abord le message. 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, et un `prepend` de liste vide n'émet pas. Affirmer « le scan
n'a pas démarré » a fait chercher au mauvais endroit pendant trois itérations.
Le message énonce désormais les deux possibilités, et le test qui verrouillait
l'ancienne formulation vérifie maintenant qu'on n'affirme rien de trop.

Ensuite le filtre. `scanPreFilter` rejetait tout ce qui n'avait ni polarDeviceId
ni « polar » dans le nom. Si la Vantage ne s'annonce pas ainsi, c'est NOUS qui
l'écartions, aucune session n'était créée, et le silence qui suivait était
interprété comme une panne du SDK. On laisse donc tout remonter et on trie
après : un test de ressemblance volontairement large (polarDeviceId,
polarDeviceType, ou nom contenant polar/vantage/grit), et on n'ouvre de session
que sur les candidats — ouvrir 43 sessions coûtait des minutes.

Et si rien ne ressemble à un Polar, on RAPPORTE ce qu'on a vu : nombre et noms
des huit premiers. C'est la seule façon de savoir sous quel nom la montre
s'annonce, ou de constater qu'elle ne s'annonce pas du tout — auquel cas
l'hypothèse du lien exclusif avec Polar Flow devient la bonne.

84 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-31 13:36:28 +00:00
parent eef76daa45
commit 60dd22eaeb
3 changed files with 101 additions and 30 deletions

View File

@@ -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: <CBPeripheral name = Apple Watch de Sylvain,
// state = disconnected> 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.
///

View File

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