Files
coach-ios/ios/App/App/CoachPolarBLE.swift
Sylvain Bettinelli 3cc7d935ad La montre ne diffuse pas parce qu'elle est déjà connectée : la chercher ailleurs
4020 advertisements vus — HomePod, Furbo, des sans-nom — et aucune Polar. Le
scan marchait donc parfaitement depuis le correctif précédent. C'est la montre
qui ne s'annonce pas.

Et c'est normal : un appareil BLE connecté cesse d'émettre. La Vantage est liée
à l'iPhone par l'app Flow, donc invisible au scan par conception. Tous les
correctifs de scan qui ont précédé ne pouvaient rien y changer — on cherchait
dans le seul endroit où elle ne pouvait pas être.

`search()` prévoit exactement ce cas, mais seulement si on lui passe des UUID :
`manager.retrieveConnectedPeripherals(withServices: uuids!)` rend les
périphériques DÉJÀ connectés exposant le service, et leur fabrique une session.
Je passais `nil`, donc cette branche n'était jamais empruntée.

Au passage, déduplication des sessions : AllowDuplicates est armé côté SDK, d'où
les 4020 pour une poignée d'appareils réels — sans quoi on ouvrirait quarante
fois la même session.

Ce que la journée aura montré : chaque message d'erreur qui affirmait une cause
unique a fait perdre du temps, et chaque message qui RAPPORTAIT ce qu'il avait vu
a fait avancer. La liste des appareils vus valait tous les raisonnements.

84 tests au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:40:12 +00:00

616 lines
29 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// CoachPolarBLE.swift
// Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud
// ni compte ni API tierce.
//
// POURQUOI CE PLUGIN EXISTE
//
// L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée
// depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle
// ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq
// minutes avant de sortir ce qui n'est pas un usage.
//
// Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin
// utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le
// 2026-08-31 `coach la montre`, sans TrainingPeaks, sans Polar Flow, sans
// Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre.
//
// CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS
//
// Il transporte. Rien d'autre. Les octets viennent du serveur
// (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé
// octet pour octet contre des fichiers relus sur la montre. **Ne jamais
// réencoder un objectif ici** : ce serait perdre la seule garantie dont on
// dispose que le firmware acceptera le fichier.
//
// LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE
//
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » donc
// « écrire du contenu dans un dossier » a bloqué une Vantage V3 : logo Polar
// puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse
// pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le
// risque est identique ici.
//
// D'où deux verrous, repris de `tools/polar/polar_ftp.py` :
// - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ;
// - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide.
// Et un mode `dryRun` qui construit chaque requête et la décrit sans rien
// émettre l'équivalent du `--dry-run` qui a manqué le jour du plantage.
//
// CHAÎNE SDK vérifiée sur les sources le 2026-08-31, tout est public
//
// CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central
// listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher<BleDeviceSession, Error>
// listener.openSessionDirect(_:)
// session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE")
// client.waitPsFtpReady(_:) async throws
// client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream<UInt, Error>
//
// Aucun fork, aucun symbole interne.
//
// Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA
// PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est
// le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les
// données brutes. Reporter le cadrage produirait une requête malformée c'est-
// à-dire précisément ce qui plante la montre.
//
// USAGE CÔTÉ JS
//
// await window.Capacitor.Plugins.CoachPolarBLE.isAvailable()
// { available: bool }
//
// await window.Capacitor.Plugins.CoachPolarBLE.send({
// mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'],
// dir: '/U/0/20260831/TST/180000/',
// files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }],
// dryRun: true, // par défaut FALSE, mais à utiliser au premier essai
// timeoutSec: 30,
// })
// { sent: bool, dryRun: bool, log: [string] }
//
// Le corps de `send` est exactement la réponse de `/api/plan/polar-target` :
// le JS n'a rien à recomposer.
import Foundation
import Capacitor
#if canImport(PolarBleSdk)
import PolarBleSdk
import CoreBluetooth
import Combine
#endif
@objc(CoachPolarBLEPlugin)
public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
// CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8
// n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE`
// reste undefined (même piège que CoachWorkoutKit).
public let identifier = "CoachPolarBLEPlugin"
public let jsName = "CoachPolarBLE"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
]
@objc func isAvailable(_ call: CAPPluginCall) {
#if canImport(PolarBleSdk)
call.resolve(["available": true])
#else
call.resolve(["available": false,
"reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"])
#endif
}
@objc func send(_ call: CAPPluginCall) {
guard let etapes = Self.parseSteps(call) else {
call.reject("payload invalide : `dir`, `files[]` (name + b64) requis")
return
}
let dryRun = call.getBool("dryRun") ?? false
let timeout = call.getDouble("timeoutSec") ?? 30
// Les verrous d'abord, la radio ensuite. Une requête refusée ici est une
// montre qui ne plante pas.
do {
try etapes.forEach { try $0.validate() }
} catch {
call.reject("\(error)")
return
}
if dryRun {
call.resolve(["sent": false, "dryRun": true,
"log": etapes.map { $0.describe }])
return
}
#if canImport(PolarBleSdk)
Task {
do {
let journal = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
call.resolve(["sent": true, "dryRun": false, "log": journal])
} catch {
call.reject("\(error)")
}
}
#else
call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode")
#endif
}
/// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir.
static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? {
guard let dir = call.getString("dir"), !dir.isEmpty else { return nil }
var etapes: [PftpStep] = []
// Les dossiers d'abord, du parent vers l'enfant : `/U/0/<date>/TST/` puis
// `<heure>/`. Aucun des deux n'existe d'avance pour une date neuve, et un
// mkdir dans le désordre échoue.
for chemin in call.getArray("mkdir", String.self) ?? [] {
etapes.append(PftpStep(path: chemin, data: Data()))
}
guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else {
return nil
}
for fichier in fichiers {
guard let nom = fichier["name"] as? String,
let b64 = fichier["b64"] as? String,
let octets = Data(base64Encoded: b64) else { return nil }
etapes.append(PftpStep(path: dir + nom, data: octets))
}
return etapes
}
}
// MARK: - Le transport
#if canImport(PolarBleSdk)
/// Ouvre une session PsFTP sur la première montre qui répond, et écrit.
///
/// Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant
/// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et
/// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu
/// en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de
/// lui-même.
final class PolarPsFtpWriter {
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
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] {
// PRÉAMBULE INDISPENSABLE sans lui, rien ne se passe et rien ne le
// dit. `CBDeviceListenerImpl.search()` commence par
// `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état
// n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on
// conclut « aucun appareil » au bout du timeout.
//
// Or l'état ne peut arriver que si un `CBCentralManager` existe et
// c'est sa création qui déclenche l'alerte d'autorisation d'iOS.
// Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi
// que ce soit, et « Bluetooth » n'apparaissait même pas dans les
// réglages de l'app. L'autorisation n'avait jamais été sollicitée.
//
// On crée donc le manager nous-mêmes, on attend son premier état, et on
// traduit ce qu'il dit au lieu de laisser un silence passer pour une
// absence de montre.
let sonde = SondeBluetooth()
try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15))
let listener = CBDeviceListenerImpl(
queue,
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
identifier: 1)
// NE PAS FILTRER À L'AVEUGLE on trie après avoir vu.
//
// 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é ».
//
// 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.
//
// `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.
//
// ET SURTOUT : attendre le SUJET, pas l'état du manager.
//
// `blePowered()` lit `manager.state` l'état de CoreBluetooth. Mais
// `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par
// `centralManagerDidUpdateState`. Le manager peut donc être allumé
// (`blePowered()` vrai) sans que le délégué ait encore publié quoi que
// ce soit : le sujet reste à `.unknown`, le filtre bloque, et le
// publisher se tait. C'est exactement ce qui a été mesuré le
// 2026-08-31, y compris après avoir attendu `blePowered()`.
//
// On attend donc sur `monitorBleState()`, qui est public et qui EST la
// source lue par `search()`. `blePowered()` ne sert plus qu'à une
// chose : instancier la lazy pour que le délégué puisse tourner.
_ = listener.blePowered()
let etat = await listener.premierEtatPret(timeout: min(timeout, 12))
if !etat {
throw PolarPftpError.bluetoothUnusable(
"le listener du SDK Polar n'a jamais publié l'état « prêt » "
+ "(son délégué n'a pas été appelé). Bluetooth actif côté "
+ "système, mais inutilisable par le SDK.")
}
let (client, session) = try await trouverClient(listener, timeout: timeout,
sonde: sonde)
defer { listener.closeSessionDirect(session) }
var journal: [String] = []
for etape in etapes {
// Revalidé juste avant l'émission : entre la validation d'entrée et
// ce point, rien ne doit avoir introduit un chemin de dossier avec
// du contenu.
try etape.validate()
try await put(client, etape)
journal.append("" + etape.describe)
}
return journal
}
private func trouverClient(_ listener: CBDeviceListenerImpl,
timeout: Double,
sonde: SondeBluetooth) async throws
-> (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
// UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE pas des fenêtres.
//
// 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.
//
// L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été
// sondé jusqu'à devenir vrai.
do {
// `AllowDuplicates` est armé côté SDK : le même appareil revient à
// chaque advertisement (4020 émissions pour une poignée d'appareils
// réels). On déduplique avant de compter et d'ouvrir quoi que ce soit.
var dejaVues = Set<String>()
let toutes = try await sessionsVues(listener, fenetre: timeout)
.filter { dejaVues.insert(Self.etiquette($0)).inserted }
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.
if Date().timeIntervalSince(debut) > timeout * 2 { break }
listener.openSessionDirect(session)
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
as? BlePsFtpClient else {
sansPsFtp += 1
listener.closeSessionDirect(session)
continue
}
do {
// `waitPsFtpReady` n'a AUCUNE limite de temps. Si la
// montre ne finit pas la négociation canal déjà pris,
// écran éteint, appairage en cours l'attente ne rend
// jamais la main et l'envoi paraît figé, sans message.
// Constaté le 2026-08-31 : « envoi » tournant sans fin.
// On lui donne donc une échéance, et un dépassement compte
// comme une montre muette : c'est ce qu'il est.
try await Self.avecEcheance(seconds: 12) {
try await client.waitPsFtpReady(true)
}
return (client, session)
} catch {
muettes += 1
listener.closeSessionDirect(session)
}
}
}
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.
let bruts = await sonde.compterAppareils(pendant: 6)
throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts,
publisherAParle: publisherAParle,
erreurSdk: derniereErreurSdk)
}
throw PolarPftpError.watchNotFound(timeout, vues: vues,
sansPsFtp: sansPsFtp, muettes: muettes)
}
/// Les appareils vus pendant une fenêtre de recherche, appairés compris.
private func sessionsVues(_ listener: CBDeviceListenerImpl,
fenetre: Double) async throws -> [BleDeviceSession] {
try await withCheckedThrowingContinuation { suite in
var vues: [BleDeviceSession] = []
var rendu = false
let rendre = {
guard !rendu else { return }
rendu = true
suite.resume(returning: vues)
}
// PASSER L'UUID DU SERVICE c'est ce qui trouve une montre DÉJÀ
// CONNECTÉE.
//
// Mesuré le 2026-08-31 : 4020 advertisements vus (HomePod, Furbo),
// aucune Polar. Normal **un appareil BLE connecté cesse
// d'émettre**. La Vantage est liée à l'iPhone par l'app Flow : elle
// est donc invisible au scan, par conception, et l'attendre était
// sans espoir.
//
// Avec un `uuids` non nil, `search()` emprunte une autre branche :
// `manager.retrieveConnectedPeripherals(withServices: uuids!)`, qui
// rend les périphériques déjà connectés au système exposant ce
// service, et leur fabrique une session. Passer `nil` ce que
// faisait ce code n'exécutait jamais cette branche.
listener.search([BlePsFtpClient.PSFTP_SERVICE],
identifiers: nil, fetchKnownDevices: true)
.sink(receiveCompletion: { [weak self] fin in
// 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)
// 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() + fenetre) { rendre() }
}
}
/// 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.
///
/// Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état
/// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute.
///
/// 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
/// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit
/// dire pourquoi, pas tourner en silence.
static func avecEcheance<T: Sendable>(seconds: Double,
_ operation: @escaping @Sendable () async throws -> T)
async throws -> T {
try await withThrowingTaskGroup(of: T.self) { groupe in
groupe.addTask { try await operation() }
groupe.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw PolarPftpError.echeanceDepassee(seconds)
}
guard let premier = try await groupe.next() else {
throw PolarPftpError.echeanceDepassee(seconds)
}
groupe.cancelAll()
return premier
}
}
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
let entree = InputStream(data: etape.data)
// Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas
// doit échouer, pas figer l'app.
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
// et l'absence d'erreur vaut acquittement c'est l'équivalent BLE du
// `05 00 00` observé en USB.
try await Self.avecEcheance(seconds: 20) {
for try await _ in client.write(etape.header() as NSData, data: entree) {}
}
}
}
#endif
/// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas.
///
/// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier
/// état. C'est cette instanciation qui provoque la demande d'autorisation iOS
/// et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app.
private final class SondeBluetooth: NSObject, CBCentralManagerDelegate {
private var manager: CBCentralManager?
private var suite: CheckedContinuation<CBManagerState, Never>?
private var repondu = false
private var vus = Set<UUID>()
func attendreEtatUtilisable(timeout: Double) async throws {
let etat = await withCheckedContinuation { (c: CheckedContinuation<CBManagerState, Never>) in
suite = c
// `showPowerAlert: false` : c'est nous qui expliquons, pas une
// alerte système au milieu d'un envoi.
manager = CBCentralManager(delegate: self, queue: nil,
options: [CBCentralManagerOptionShowPowerAlertKey: false])
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
self?.repondre(self?.manager?.state ?? .unknown)
}
}
switch etat {
case .poweredOn:
return
case .unauthorized:
throw PolarPftpError.bluetoothUnusable(
"coach n'a pas l'autorisation d'utiliser le Bluetooth. "
+ "Réglages → coach → activer Bluetooth.")
case .poweredOff:
throw PolarPftpError.bluetoothUnusable(
"le Bluetooth est désactivé sur l'iPhone.")
case .unsupported:
throw PolarPftpError.bluetoothUnusable(
"cet appareil ne prend pas en charge le Bluetooth LE.")
default:
throw PolarPftpError.bluetoothUnusable(
"le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). "
+ "Réessayer ; si ça persiste, redémarrer l'app.")
}
}
/// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK.
///
/// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit
/// des appareils et que le SDK n'en remonte aucun, le problème est dans
/// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou
/// l'environnement. Sans cette mesure on ne peut que deviner ce qui a
/// coûté trois allers-retours le 2026-08-31.
func compterAppareils(pendant: Double) async -> Int {
vus.removeAll()
manager?.scanForPeripherals(withServices: nil,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000))
manager?.stopScan()
return vus.count
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
vus.insert(peripheral.identifier)
}
private func repondre(_ etat: CBManagerState) {
guard !repondu else { return }
repondu = true
suite?.resume(returning: etat)
suite = nil
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// `.unknown` est l'état transitoire du démarrage : ne pas conclure
// dessus, le vrai état suit.
if central.state != .unknown { repondre(central.state) }
}
}
extension CBDeviceListenerImpl {
/// Vrai dès que le listener PUBLIE l'état `.poweredOn` c'est-à-dire dès
/// que `search()` pourra franchir son filtre.
///
/// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement
/// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les
/// suivantes. On attend la première qui vaut `.poweredOn`.
/// Porte son échéance elle-même plutôt que d'être enveloppée : le listener
/// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait
/// à la concurrence stricte de Swift 6.
func premierEtatPret(timeout: Double) async -> Bool {
await withCheckedContinuation { suite in
let boite = BoiteJeton()
boite.jeton = monitorBleState()
.sink(receiveCompletion: { _ in boite.rendre(false, suite) },
receiveValue: { etat in
if etat == .poweredOn { boite.rendre(true, suite) }
})
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
boite.rendre(false, suite)
}
}
}
}
/// Garde l'abonnement en vie et garantit une reprise unique de la continuation
/// la reprendre deux fois est un crash, et il y a ici deux chemins de sortie
/// concurrents : l'état publié et l'échéance.
private final class BoiteJeton: @unchecked Sendable {
var jeton: AnyCancellable?
private var rendu = false
private let verrou = NSLock()
func rendre(_ valeur: Bool, _ suite: CheckedContinuation<Bool, Never>) {
verrou.lock()
defer { verrou.unlock() }
guard !rendu else { return }
rendu = true
suite.resume(returning: valeur)
jeton?.cancel()
jeton = nil
}
}