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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user