CurrentValueSubject, pas Passthrough : il fallait réveiller AVANT, pas après
La lecture des sources tranche ce que trois essais n'avaient pas résolu, et elle retourne mon raisonnement précédent. `bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)` : il rejoue sa valeur à chaque abonnement. Être abonné au moment de l'émission n'apporte donc rien — et le correctif intermédiaire, qui réveillait le manager APRÈS s'être abonné « pour ne pas rater l'événement », était un raisonnement de PassthroughSubject appliqué au mauvais type. Pire, il exposait à un second piège : `centralManagerDidUpdateState` fait `BleState(rawValue: self.manager.state.rawValue)`, soit un accès à la lazy var DEPUIS le délégué. Réveillée au milieu d'un abonnement, la propriété peut se réentrer avant la fin de sa propre initialisation. Séquence corrigée : créer le listener, poser le filtre, sonder `blePowered()` jusqu'à ce qu'il réponde vrai — hors de tout abonnement, le premier appel instancie, les suivants observent — puis appeler search() une seule fois. Si l'état n'est pas atteint en 10 s, on le dit au lieu de scanner dans le vide. Le commentaire qui affirmait « bleStateSubject ne publie que sur changement » est corrigé plutôt que laissé : il était faux, et un commentaire qui ment coûte plus cher que pas de commentaire. L'abonnement unique reste, mais pour la vraie raison — chaque abonnement relance le cycle addClient/removeClient du scanner, et la découverte BLE demande plusieurs secondes ininterrompues. 82 tests au vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -166,13 +166,28 @@ abonné à `search()`. L'ordre est critique : si `bleStateSubject` est un
|
||||
`PassthroughSubject`, un état émis avant l'abonnement serait perdu et on
|
||||
retomberait sur le même silence.
|
||||
|
||||
## 🔴 ÉTAT AU SOIR DU 31/08 : bloqué, et pourquoi il faut changer d'approche
|
||||
## Le démarrage du SDK — deux détails qui décident de tout
|
||||
|
||||
Malgré le réveil du manager, le filtre Polar et l'abonnement unique, le
|
||||
publisher de `search()` **reste muet** : `bleStateSubject` n'émet jamais
|
||||
`.poweredOn`. Le manager du SDK existe pourtant — l'avertissement CoreBluetooth
|
||||
`SDKCBCentralManager … has no restore identifier` ne peut apparaître que s'il a
|
||||
été instancié.
|
||||
Lus dans les sources, après plusieurs essais infructueux le 31/08 :
|
||||
|
||||
**1. `bleStateSubject` est un `CurrentValueSubject<BleState, Never>(.unknown)`**,
|
||||
pas un PassthroughSubject. Il **rejoue sa valeur à chaque abonnement** : il est
|
||||
donc inutile d'être abonné au moment de l'émission. Un correctif intermédiaire
|
||||
réveillait le manager *après* s'être abonné « pour ne pas rater l'événement » —
|
||||
raisonnement juste pour un PassthroughSubject, faux ici, et qui exposait au
|
||||
piège suivant.
|
||||
|
||||
**2. `centralManagerDidUpdateState` fait
|
||||
`BleState(rawValue: self.manager.state.rawValue)`** — il accède à la `lazy var`
|
||||
depuis le délégué. Si CoreBluetooth appelle le délégué avant que l'initialisation
|
||||
de la lazy soit terminée, la propriété se réentre.
|
||||
|
||||
⇒ **Séquence correcte** : créer le listener, poser `scanPreFilter`, réveiller la
|
||||
lazy en sondant `blePowered()` jusqu'à ce qu'elle réponde vrai — hors de tout
|
||||
abonnement — et seulement ensuite appeler `search()`, une fois, sur toute la
|
||||
durée. Un abonnement par tranche relancerait le cycle
|
||||
`addClient()`/`removeClient()` du scanner et l'empêcherait de découvrir quoi que
|
||||
ce soit.
|
||||
|
||||
**Ce qui a été corrigé et vérifié en chemin** (ne pas refaire) :
|
||||
|
||||
|
||||
@@ -228,6 +228,42 @@ final class PolarPsFtpWriter {
|
||||
|| contenu.name.lowercased().contains("polar")
|
||||
}
|
||||
|
||||
// ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout
|
||||
// abonnement.
|
||||
//
|
||||
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
|
||||
// CBCentralManager n'existe qu'au premier accès, et `search()` ne le
|
||||
// touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on
|
||||
// attend un état que rien ne peut produire.
|
||||
//
|
||||
// Deux détails, tirés des sources, qui décident du succès :
|
||||
//
|
||||
// 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à
|
||||
// `.unknown`. Il REJOUE sa valeur à chaque abonnement — inutile donc
|
||||
// d'être abonné au moment de l'émission. Une version précédente
|
||||
// réveillait après l'abonnement « pour ne pas rater l'événement » :
|
||||
// raisonnement de PassthroughSubject, faux ici, et qui exposait au
|
||||
// piège suivant.
|
||||
//
|
||||
// 2. `centralManagerDidUpdateState` fait
|
||||
// `BleState(rawValue: self.manager.state.rawValue)` — il accède à la
|
||||
// lazy DEPUIS le délégué. Si CoreBluetooth appelle le délégué avant
|
||||
// que l'initialisation de la lazy soit terminée, la propriété se
|
||||
// réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser
|
||||
// l'état se poser.
|
||||
//
|
||||
// On attend donc que `blePowered()` réponde vrai — ce qui signifie que
|
||||
// le délégué a tourné et que `.poweredOn` est dans le sujet — avant
|
||||
// d'appeler `search()`.
|
||||
let pret = await Self.attendre(seconds: min(timeout, 10)) {
|
||||
listener.blePowered()
|
||||
}
|
||||
if !pret {
|
||||
throw PolarPftpError.bluetoothUnusable(
|
||||
"le listener du SDK Polar n'a pas atteint l'état « prêt ». "
|
||||
+ "Bluetooth actif mais inutilisable par le SDK.")
|
||||
}
|
||||
|
||||
let (client, session) = try await trouverClient(listener, timeout: timeout,
|
||||
sonde: sonde)
|
||||
defer { listener.closeSessionDirect(session) }
|
||||
@@ -255,16 +291,15 @@ final class PolarPsFtpWriter {
|
||||
|
||||
// ⚠️ UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE — pas des fenêtres.
|
||||
//
|
||||
// Le découpage en cycles de 4 s se privait de l'unique émission d'état :
|
||||
// `bleStateSubject` ne publie que sur CHANGEMENT. Le premier cycle
|
||||
// réveillait le manager et recevait `.poweredOn` ; aux cycles suivants
|
||||
// l'état ne changeait plus, donc plus rien n'était émis et le publisher
|
||||
// restait muet — mesuré le 2026-08-31, y compris après avoir réglé le
|
||||
// réveil du manager.
|
||||
// Non pas parce qu'une émission serait ratée — `bleStateSubject` est un
|
||||
// CurrentValueSubject, il rejoue sa valeur à chaque abonnement — mais
|
||||
// parce que chaque abonnement relance le cycle `addClient()` /
|
||||
// `removeClient()` du scanner. Redémarrer un scan toutes les 4 s
|
||||
// l'empêche de découvrir quoi que ce soit, et la découverte BLE demande
|
||||
// plusieurs secondes ininterrompues.
|
||||
//
|
||||
// Une seule fenêtre, de la durée du timeout, supprime le problème : on
|
||||
// reçoit l'émission là où elle a lieu, et le scan tourne sans être
|
||||
// relancé.
|
||||
// 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
|
||||
@@ -338,28 +373,6 @@ final class PolarPsFtpWriter {
|
||||
})
|
||||
.store(in: &abonnements)
|
||||
|
||||
// ⚠️ RÉVEIL DE LA PROPRIÉTÉ LAZY — SANS ÇA, RIEN NE SE PASSE.
|
||||
//
|
||||
// `CBDeviceListenerImpl.manager` est une `lazy var` : le
|
||||
// CBCentralManager n'existe qu'au premier accès. Or `search()`
|
||||
// commence par `monitorBleState().filter { $0 == .poweredOn }`, et
|
||||
// `monitorBleState()` ne fait que rendre `bleStateSubject` — il ne
|
||||
// touche jamais `manager`. Les seuls accès du chemin de recherche
|
||||
// sont À L'INTÉRIEUR du `flatMap`, donc après le filtre.
|
||||
//
|
||||
// Résultat mesuré le 2026-08-31 : le manager n'était jamais créé,
|
||||
// `centralManagerDidUpdateState` jamais appelé, le sujet n'émettait
|
||||
// jamais, et le publisher restait muet — ni valeur, ni fin, ni
|
||||
// erreur. 43 appareils vus par un scan CoreBluetooth nu, 0 par le
|
||||
// SDK.
|
||||
//
|
||||
// `blePowered()` lit `manager.state` : c'est le seul accès public
|
||||
// exécuté immédiatement, donc celui qui instancie le manager.
|
||||
//
|
||||
// ⚠️ L'ORDRE EST CRITIQUE : réveiller APRÈS s'être abonné. Si
|
||||
// `bleStateSubject` est un PassthroughSubject, un état émis avant
|
||||
// l'abonnement serait perdu, et on retomberait sur le même silence.
|
||||
_ = listener.blePowered()
|
||||
|
||||
// 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.
|
||||
@@ -367,6 +380,21 @@ final class PolarPsFtpWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout
|
||||
/// du délai.
|
||||
///
|
||||
/// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et
|
||||
/// c'est justement cette lecture qui instancie le CBCentralManager. Le
|
||||
/// premier appel réveille, les suivants observent.
|
||||
static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool {
|
||||
let echeance = Date().addingTimeInterval(seconds)
|
||||
while Date() < echeance {
|
||||
if condition() { return true }
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user