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>
616 lines
29 KiB
Swift
616 lines
29 KiB
Swift
// 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
|
||
}
|
||
}
|