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

@@ -177,6 +177,23 @@ final class PolarPsFtpWriter {
private var abonnements = Set<AnyCancellable>()
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(
queue,
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
@@ -265,3 +282,59 @@ final class PolarPsFtpWriter {
}
#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.
case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int)
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
/// 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
case .psftpUnavailable:
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
case .bluetoothUnusable(let why):
return why
case .watchNotFound(let seconds, 0, _, _):
// Rien du tout : le problème est en amont de la montre.