iOS n'avait jamais demandé l'autorisation Bluetooth, et le scan attendait un état qui n'arrivait pas

« aucun appareil Bluetooth détecté » après 30 s de scan. La cause n'était pas la
montre : Réglages → coach ne contenait même pas de ligne Bluetooth. L'alerte
d'autorisation n'avait jamais été présentée.

La raison est dans le SDK. `CBDeviceListenerImpl.search()` commence par
`monitorBleState().filter { $0 == .poweredOn }` — tant que cet état n'arrive
pas, le flux n'émet rien et le scan ne démarre jamais. Or l'état ne peut venir
que d'un CBCentralManager, et c'est sa création qui déclenche la demande
d'autorisation. Aucun manager n'existant, on attendait un signal que rien ne
pouvait produire, puis on concluait « aucun appareil » au timeout. Avoir la clé
NSBluetoothAlwaysUsageDescription dans Info.plist ne suffit pas : elle fournit
le texte de l'alerte, elle ne la provoque pas.

`SondeBluetooth` crée donc le manager, attend son premier état non-`.unknown`
(l'état transitoire du démarrage, sur lequel il ne faut pas conclure), et
traduit ce qu'il dit : autorisation refusée, Bluetooth éteint, matériel absent.
Le cas d'erreur est distinct de watchNotFound — on n'a même pas pu chercher, et
parler de la montre envoyait chercher au mauvais endroit. Deux tests verrouillent
cette distinction.

Au premier lancement après ce correctif, iOS présentera l'alerte : il faut
l'accepter. Ensuite seulement on saura si la Vantage accepte une connexion
tierce, qui reste la question de fond.

75 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-31 12:57:02 +00:00
parent 179528d563
commit 6deb61e86e
4 changed files with 117 additions and 2 deletions

View File

@@ -42,8 +42,21 @@ Linux (`./tests-linux/run.sh`).
## 3. Permission Bluetooth ## 3. Permission Bluetooth
`NSBluetoothAlwaysUsageDescription` est déjà dans `Info.plist`, en français. `NSBluetoothAlwaysUsageDescription` est dans `Info.plist`, en français.
iOS demandera l'autorisation au premier scan.
⚠️ **La clé ne suffit pas à déclencher la demande.** iOS ne sollicite
l'autorisation qu'à la création d'un `CBCentralManager`. Or
`CBDeviceListenerImpl.search()` commence par
`monitorBleState().filter { $0 == .poweredOn }` : sans manager, cet état
n'arrive jamais, le flux n'émet rien, le scan ne démarre pas — et le timeout
conclut « aucun appareil ». Mesuré le 31/08 : 30 s de scan, aucune alerte, et
« Bluetooth » absent de Réglages → coach (seuls Position, Caméra, Mouvement,
Actualisation, Données cellulaires y figuraient).
C'est pourquoi `PolarPsFtpWriter` commence par une `SondeBluetooth` : elle crée
le manager, attend son premier état, et traduit ce qu'il dit — autorisation
refusée, Bluetooth éteint, matériel absent. **Au premier lancement après ce
correctif, iOS affichera l'alerte d'autorisation : l'accepter.**
## 4. Le premier envoi — en `dryRun`, toujours ## 4. Le premier envoi — en `dryRun`, toujours

View File

@@ -177,6 +177,23 @@ final class PolarPsFtpWriter {
private var abonnements = Set<AnyCancellable>() private var abonnements = Set<AnyCancellable>()
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] { func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] {
// PRÉAMBULE INDISPENSABLE sans lui, rien ne se passe et rien ne le
// 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.
try await SondeBluetooth().attendreEtatUtilisable(timeout: min(timeout, 15))
let listener = CBDeviceListenerImpl( let listener = CBDeviceListenerImpl(
queue, queue,
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }], clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
@@ -265,3 +282,59 @@ final class PolarPsFtpWriter {
} }
#endif #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
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.")
}
}
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) }
}
}

View File

@@ -103,6 +103,9 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
/// `muettes` : avec FEEE mais qui n'ont pas répondu. /// `muettes` : avec FEEE mais qui n'ont pas répondu.
case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int) case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int)
case psftpUnavailable case psftpUnavailable
/// La radio elle-même est inutilisable : autorisation, Bluetooth éteint,
/// matériel. Distinct de `watchNotFound` ici on n'a même pas pu chercher.
case bluetoothUnusable(String)
/// Un échec de connexion a plusieurs causes OPPOSÉES, et un message /// Un échec de connexion a plusieurs causes OPPOSÉES, et un message
/// unique les confond c'est ce qui s'est produit au premier essai du /// unique les confond c'est ce qui s'est produit au premier essai du
@@ -113,6 +116,8 @@ public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
return why return why
case .psftpUnavailable: case .psftpUnavailable:
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu" return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
case .bluetoothUnusable(let why):
return why
case .watchNotFound(let seconds, 0, _, _): case .watchNotFound(let seconds, 0, _, _):
// Rien du tout : le problème est en amont de la montre. // Rien du tout : le problème est en amont de la montre.

View File

@@ -156,3 +156,27 @@ final class PolarPftpErrorTests: XCTestCase {
XCTAssertEqual(messages.count, 3) XCTAssertEqual(messages.count, 3)
} }
} }
// MARK: - « La radio ne marche pas » « la montre est introuvable »
//
// Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » alors que
// la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation
// Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app.
// Un message parlant de la montre envoyait chercher au mauvais endroit.
extension PolarPftpErrorTests {
func testRadioInutilisableNeParlePasDeLaMontre() {
let e = PolarPftpError.bluetoothUnusable(
"coach n'a pas l'autorisation d'utiliser le Bluetooth.")
XCTAssertFalse(e.description.contains("montre"))
XCTAssertTrue(e.description.contains("autorisation"))
}
func testRadioInutilisableEstDistinctDeMontreIntrouvable() {
let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description
let montre = PolarPftpError.watchNotFound(30, vues: 0,
sansPsFtp: 0, muettes: 0).description
XCTAssertNotEqual(radio, montre)
}
}