Le scan du SDK ne démarre pas, et le message le dira au lieu d'accuser la montre
43 appareils vus par un scan CoreBluetooth nu, 0 remonté par le SDK Polar. La
radio, l'autorisation et la montre sont hors de cause — c'est notre usage du SDK.
Trois suspects écartés sur les sources plutôt que supposés, et écrits dans le
runbook pour que personne ne les revérifie : servicesToScanFor à nil est aussi
ce que fait PolarBleApiImpl ; un scanPreFilter nil ACCEPTE les appareils (le
`if let` échoue, le rejet est sauté) ; et addClient() suffit à lancer le scan,
scanningNeeded() rendant vrai dès que clientCount != 0.
Reste une hypothèse : search() commence par
`monitorBleState().filter { $0 == .poweredOn }`. Si cet état n'arrive jamais, le
publisher ne dit RIEN — ni valeur, ni complétion, ni erreur — et le timeout
conclut « aucun appareil » alors qu'on n'a jamais cherché. Ce sont deux
diagnostics opposés que le même message couvrait.
L'instrumentation note donc si le publisher a parlé, et relaie une éventuelle
erreur du SDK telle quelle. Trois messages distincts en sortent, trois tests les
verrouillent.
Piste notée pour la reprise : le SDK pose powerStateObserver et
deviceSessionStateObserver sur son listener et passe identifier: 0 ; nous ne
posons aucun observateur et passons 1.
80 tests au vert sur tests-linux.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -119,10 +119,38 @@ réussi : écrire un objectif daté 18 h et regarder s'il apparaît immédiateme
|
|||||||
- S'il n'apparaît qu'à l'approche → il faut écrire à l'heure du départ. Sans
|
- S'il n'apparaît qu'à l'approche → il faut écrire à l'heure du départ. Sans
|
||||||
conséquence pratique : l'envoi se fait de toute façon juste avant de sortir.
|
conséquence pratique : l'envoi se fait de toute façon juste avant de sortir.
|
||||||
|
|
||||||
|
## 🔴 Où ça bloque au 31/08 : le scan du SDK ne démarre pas
|
||||||
|
|
||||||
|
Mesuré, pas supposé : **43 appareils vus par un scan CoreBluetooth nu, 0 remonté
|
||||||
|
par le SDK Polar.** La radio, l'autorisation et la montre sont donc hors de
|
||||||
|
cause — le problème est dans notre usage du SDK.
|
||||||
|
|
||||||
|
Ce qui a été écarté **sur les sources**, pour ne pas le refaire :
|
||||||
|
|
||||||
|
- `servicesToScanFor` à `nil` : c'est aussi ce que fait `PolarBleApiImpl`.
|
||||||
|
- `scanPreFilter` à `nil` : **accepte** les appareils. La condition est
|
||||||
|
`if self.session(peripheral) == nil, let filter = self.scanPreFilter` — sans
|
||||||
|
filtre, le `if let` échoue et le rejet est sauté.
|
||||||
|
- `addClient()` suffit à lancer le scan : `scanningNeeded()` rend vrai dès que
|
||||||
|
`clientCount != 0`, aucune action « admin » n'est requise.
|
||||||
|
|
||||||
|
**L'hypothèse qui reste**, et que l'instrumentation ajoutée doit confirmer :
|
||||||
|
`search()` commence par `monitorBleState().filter { $0 == .poweredOn }`. Si cet
|
||||||
|
état n'arrive jamais, le publisher ne dit **rien** — ni valeur, ni complétion,
|
||||||
|
ni erreur — et le timeout conclut « aucun appareil » alors que le scan n'a
|
||||||
|
jamais démarré. Le message distingue désormais ce cas (« le SDK n'a jamais
|
||||||
|
signalé que le Bluetooth était prêt ») des autres.
|
||||||
|
|
||||||
|
⚠️ **Piste à explorer en premier à la reprise** : le SDK positionne
|
||||||
|
`powerStateObserver` et `deviceSessionStateObserver` sur son
|
||||||
|
`CBDeviceListenerImpl`, et passe `identifier: 0`. Notre code ne pose aucun
|
||||||
|
observateur et passe `identifier: 1`. Vérifier si `monitorBleState()` dépend
|
||||||
|
d'un de ces réglages.
|
||||||
|
|
||||||
## Ce qui reste inconnu, et ne se lèvera qu'ici
|
## Ce qui reste inconnu, et ne se lèvera qu'ici
|
||||||
|
|
||||||
1. **La V3 accepte-t-elle une connexion BLE tierce** alors qu'elle est appairée
|
1. **La V3 accepte-t-elle une connexion BLE tierce** alors qu'elle est appairée
|
||||||
à l'app Flow ? Jamais testé. C'est le risque principal.
|
à l'app Flow ? Toujours pas testé — on n'a pas encore atteint la montre.
|
||||||
2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) »,
|
2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) »,
|
||||||
pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé,
|
pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé,
|
||||||
et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un
|
et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
final class PolarPsFtpWriter {
|
final class PolarPsFtpWriter {
|
||||||
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
|
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
|
||||||
private var abonnements = Set<AnyCancellable>()
|
private var abonnements = Set<AnyCancellable>()
|
||||||
|
/// Le publisher de `search()` a-t-il émis quoi que ce soit — valeur, fin ou
|
||||||
|
/// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis
|
||||||
|
/// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun
|
||||||
|
/// appareil », c'est « on n'a jamais cherché ».
|
||||||
|
private var publisherAParle = false
|
||||||
|
private var derniereErreurSdk: String?
|
||||||
|
|
||||||
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] {
|
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] {
|
||||||
// ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le
|
// ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le
|
||||||
@@ -254,7 +260,9 @@ final class PolarPsFtpWriter {
|
|||||||
// Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ?
|
// 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.
|
// Un scan nu tranche, et son résultat part dans le message.
|
||||||
let bruts = await sonde.compterAppareils(pendant: 6)
|
let bruts = await sonde.compterAppareils(pendant: 6)
|
||||||
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts)
|
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts,
|
||||||
|
publisherAParle: publisherAParle,
|
||||||
|
erreurSdk: derniereErreurSdk)
|
||||||
}
|
}
|
||||||
throw PolarPftpError.watchNotFound(timeout, vues: vues,
|
throw PolarPftpError.watchNotFound(timeout, vues: vues,
|
||||||
sansPsFtp: sansPsFtp, muettes: muettes)
|
sansPsFtp: sansPsFtp, muettes: muettes)
|
||||||
@@ -272,8 +280,21 @@ final class PolarPsFtpWriter {
|
|||||||
suite.resume(returning: vues)
|
suite.resume(returning: vues)
|
||||||
}
|
}
|
||||||
listener.search(nil, identifiers: nil, fetchKnownDevices: true)
|
listener.search(nil, identifiers: nil, fetchKnownDevices: true)
|
||||||
.sink(receiveCompletion: { _ in rendre() },
|
.sink(receiveCompletion: { [weak self] fin in
|
||||||
receiveValue: { vues.append($0) })
|
// Distinguer « le publisher a terminé » de « il n'a
|
||||||
|
// jamais rien dit » : dans le second cas, `flatMap`
|
||||||
|
// n'a pas été atteint, donc `monitorBleState()` n'a
|
||||||
|
// jamais émis `.poweredOn` — le scan n'a pas démarré.
|
||||||
|
if case .failure(let e) = fin {
|
||||||
|
self?.derniereErreurSdk = String(describing: e)
|
||||||
|
}
|
||||||
|
self?.publisherAParle = true
|
||||||
|
rendre()
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] session in
|
||||||
|
self?.publisherAParle = true
|
||||||
|
vues.append(session)
|
||||||
|
})
|
||||||
.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.
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
|||||||
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
|
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
|
||||||
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
|
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
|
||||||
/// opposés selon ce nombre.
|
/// opposés selon ce nombre.
|
||||||
case sdkSilencieux(vusParCoreBluetooth: Int)
|
case sdkSilencieux(vusParCoreBluetooth: Int,
|
||||||
|
publisherAParle: Bool = true,
|
||||||
|
erreurSdk: String? = nil)
|
||||||
|
|
||||||
/// ⚠️ Un échec de connexion a plusieurs causes OPPOSÉES, et un message
|
/// ⚠️ 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
|
/// unique les confond — c'est ce qui s'est produit au premier essai du
|
||||||
@@ -123,13 +125,25 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
|||||||
case .bluetoothUnusable(let why):
|
case .bluetoothUnusable(let why):
|
||||||
return why
|
return why
|
||||||
|
|
||||||
case .sdkSilencieux(0):
|
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."
|
||||||
|
|
||||||
|
case .sdkSilencieux(_, _, .some(let erreur)):
|
||||||
|
return "le SDK Polar a répondu par une erreur : \(erreur)"
|
||||||
|
|
||||||
|
case .sdkSilencieux(0, _, _):
|
||||||
return "aucun appareil Bluetooth alentour, même en scan direct. "
|
return "aucun appareil Bluetooth alentour, même en scan direct. "
|
||||||
+ "La radio fonctionne mais ne voit rien : montre éteinte, hors "
|
+ "La radio fonctionne mais ne voit rien : montre éteinte, hors "
|
||||||
+ "de portée, ou déjà connectée à un autre appareil de façon "
|
+ "de portée, ou déjà connectée à un autre appareil de façon "
|
||||||
+ "exclusive."
|
+ "exclusive."
|
||||||
|
|
||||||
case .sdkSilencieux(let bruts):
|
case .sdkSilencieux(let bruts, _, _):
|
||||||
return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK "
|
return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK "
|
||||||
+ "Polar n'en remonte aucun. La radio va bien : le problème est "
|
+ "Polar n'en remonte aucun. La radio va bien : le problème est "
|
||||||
+ "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone."
|
+ "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone."
|
||||||
|
|||||||
@@ -203,3 +203,34 @@ extension PolarPftpErrorTests {
|
|||||||
XCTAssertTrue(e.description.contains("La radio va bien"))
|
XCTAssertTrue(e.description.contains("La radio va bien"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé »
|
||||||
|
//
|
||||||
|
// `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`.
|
||||||
|
// Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni
|
||||||
|
// erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a
|
||||||
|
// jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le
|
||||||
|
// SDK.
|
||||||
|
|
||||||
|
extension PolarPftpErrorTests {
|
||||||
|
|
||||||
|
func testPublisherMuetNAccusePasLaMontre() {
|
||||||
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||||
|
publisherAParle: false)
|
||||||
|
XCTAssertTrue(e.description.contains("n'a pas démarré"))
|
||||||
|
XCTAssertTrue(e.description.contains("Ce n'est pas la montre"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUneErreurDuSdkEstRapporteeTelleQuelle() {
|
||||||
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||||
|
publisherAParle: true,
|
||||||
|
erreurSdk: "bleNotReady")
|
||||||
|
XCTAssertTrue(e.description.contains("bleNotReady"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPublisherActifSansSessionAccuseLIntegration() {
|
||||||
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
||||||
|
publisherAParle: true)
|
||||||
|
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user