Files
coach-ios/ios/App/App/CoachPolarBLE.swift
Sylvain Bettinelli 94b1f6243d Sans filtre, on ouvrait une session sur les 43 appareils BLE du voisinage
Les traces le disent en clair : le SDK tentait de discuter avec l'Apple Watch.

    API MISUSE: <CBPeripheral … name = Apple Watch de Sylvain,
    state = disconnected> can only accept commands while in the connected state

Sans `scanPreFilter`, le listener remonte TOUS les appareils BLE alentour — 43
mesurés ce matin. Le code ouvrait une session sur chacun à tour de rôle pour lui
demander s'il portait PsFTP, avec 12 s d'échéance par appareil. D'où l'envoi qui
tournait sans fin, et ces erreurs sur un appareil qui n'a rien à voir.

Le SDK officiel pose exactement ce filtre — `deviceFilter` dans
`PolarBleApiImpl` — et je ne l'avais pas repris. Le voilà : `polarDeviceId` non
vide, avec un repli sur le nom quand l'identifiant n'est pas encore décodé au
moment du filtrage.

C'est le pendant de la découverte précédente : le manager lazy empêchait de
voir quoi que ce soit, et une fois réveillé, l'absence de filtre faisait tout
voir. Les deux se cachaient l'un l'autre.

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

474 lines
22 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)
// FILTRER, SINON ON OUVRE UNE SESSION SUR TOUT CE QUI PASSE.
//
// 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 :
//
// 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")
}
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 sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE
var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue
// Le scan se fait par fenêtres successives, et non en une seule passe :
// CoreBluetooth peut n'être pas encore `poweredOn` au premier appel le
// scan ne démarre alors jamais, et une fenêtre unique conclurait à tort
// qu'aucun appareil n'existe. Réessayer couvre ce démarrage.
while Date().timeIntervalSince(debut) < timeout {
let sessions = try await sessionsVues(listener, fenetre: 4)
vues = max(vues, sessions.count)
for session in sessions {
if Date().timeIntervalSince(debut) > timeout { 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 {
// 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)
}
listener.search(nil, 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)
// 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.
queue.asyncAfter(deadline: .now() + fenetre) { rendre() }
}
}
/// 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) }
}
}