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>
474 lines
22 KiB
Swift
474 lines
22 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)
|
||
|
||
// ⚠️ 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) }
|
||
}
|
||
}
|