« 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>
144 lines
6.4 KiB
Swift
144 lines
6.4 KiB
Swift
// PolarPftpStep.swift
|
||
// Une opération d'écriture PFTP, et les garde-fous qui empêchent de planter la
|
||
// montre. N'importe QUE Foundation, délibérément.
|
||
//
|
||
// POURQUOI CE FICHIER EST SÉPARÉ DU PLUGIN
|
||
//
|
||
// `CoachPolarBLE.swift` importe Capacitor et le SDK Polar : il ne peut être
|
||
// compilé que sur un Mac, dans Xcode. Or ce qui est ici est exactement la
|
||
// partie qu'il faut pouvoir vérifier — la sérialisation de l'en-tête, et les
|
||
// deux règles qui décident si une requête part ou non vers le firmware.
|
||
//
|
||
// Lié dans `tests-linux/Sources/CoachModel/` par un lien symbolique : les tests
|
||
// portent sur le fichier livré, pas sur une copie qui dériverait en silence.
|
||
//
|
||
// ⚠️ CE QUE CES GARDE-FOUS PROTÈGENT
|
||
//
|
||
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » a bloqué
|
||
// une Polar Vantage V3 : logo Polar puis écran noir, récupérée par un appui
|
||
// long sur OK (10-15 s). Le firmware ne renvoie pas d'erreur applicative sur
|
||
// une requête malformée — il s'effondre. Le Bluetooth utilise le même protocole
|
||
// PFTP que l'USB : changer de transport n'enlève rien au risque.
|
||
//
|
||
// Les mêmes règles existent dans `tools/polar/polar_ftp.py::pftp_put()` du
|
||
// dépôt coach_sportif. Les deux doivent dire la même chose.
|
||
|
||
import Foundation
|
||
|
||
/// Un PUT PFTP : un chemin, un contenu. Un contenu vide crée un dossier.
|
||
public struct PftpStep: Equatable {
|
||
public let path: String
|
||
public let data: Data
|
||
|
||
public init(path: String, data: Data) {
|
||
self.path = path
|
||
self.data = data
|
||
}
|
||
|
||
/// Un chemin terminé par « / » désigne un dossier, jamais un fichier.
|
||
public var isDirectory: Bool { path.hasSuffix("/") }
|
||
|
||
public var describe: String {
|
||
"\(isDirectory ? "mkdir" : "put ") \(path) (\(data.count) o)"
|
||
}
|
||
|
||
/// Refuse les formes qui ont fait, ou feraient, planter la montre.
|
||
///
|
||
/// Dernier rempart avant l'émission : à appeler à l'entrée du plugin ET
|
||
/// juste avant chaque écriture.
|
||
public func validate() throws {
|
||
if isDirectory && !data.isEmpty {
|
||
throw PolarPftpError.malformed(
|
||
"refus d'écrire \(data.count) octets vers « \(path) » : un chemin "
|
||
+ "terminé par « / » désigne un dossier. C'est cette requête exacte "
|
||
+ "qui a fait planter une Vantage V3 le 2026-08-17.")
|
||
}
|
||
if !isDirectory && data.isEmpty {
|
||
throw PolarPftpError.malformed(
|
||
"refus d'écrire un fichier vide vers « \(path) » : ajouter « / » "
|
||
+ "pour créer un dossier, ou fournir un contenu.")
|
||
}
|
||
if !path.hasPrefix("/U/0/") {
|
||
throw PolarPftpError.malformed(
|
||
"chemin « \(path) » hors de /U/0/ : refusé par précaution, rien "
|
||
+ "d'autre n'a jamais été écrit sur cette montre.")
|
||
}
|
||
}
|
||
|
||
/// `PbPFtpOperation { command = 1 (varint), path = 2 (string) }`.
|
||
///
|
||
/// Sérialisation identique à `encode_operation()` de `polar_ftp.py` :
|
||
/// `0x08` (champ 1, varint) + commande, puis `0x12` (champ 2, délimité) +
|
||
/// longueur + chemin. `PUT` vaut 1, d'après `pftp_request.proto` du SDK
|
||
/// (`enum Command { GET = 0; PUT = 1; MERGE = 2; REMOVE = 3; }`).
|
||
///
|
||
/// ⚠️ Pas de cadrage `[0x05, taille, taille]` ici : celui-là appartient au
|
||
/// transport série RFC76 de la version USB. En Bluetooth, le SDK cadre
|
||
/// lui-même — l'ajouter produirait une requête malformée, c'est-à-dire
|
||
/// exactement ce qui plante la montre.
|
||
public func header() -> Data {
|
||
var bytes = Data([0x08, 0x01, 0x12])
|
||
let path = Array(self.path.utf8)
|
||
bytes.append(contentsOf: PftpStep.varint(path.count))
|
||
bytes.append(contentsOf: path)
|
||
return bytes
|
||
}
|
||
|
||
/// Varint protobuf, 7 bits par octet, bit de poids fort = continuation.
|
||
public static func varint(_ value: Int) -> [UInt8] {
|
||
var n = value, out: [UInt8] = []
|
||
repeat {
|
||
var b = UInt8(n & 0x7F)
|
||
n >>= 7
|
||
if n > 0 { b |= 0x80 }
|
||
out.append(b)
|
||
} while n > 0
|
||
return out
|
||
}
|
||
}
|
||
|
||
public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
||
case malformed(String)
|
||
/// `vues` : appareils BLE aperçus · `sansPsFtp` : sans le service FEEE ·
|
||
/// `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
|
||
/// 2026-08-31. Ne jamais fusionner ces branches.
|
||
public var description: String {
|
||
switch self {
|
||
case .malformed(let why):
|
||
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.
|
||
return "aucun appareil Bluetooth détecté en \(Int(seconds)) s. "
|
||
+ "Vérifier, dans l'ordre : le Bluetooth activé sur l'iPhone ; "
|
||
+ "l'autorisation Bluetooth accordée à coach (Réglages → coach) ; "
|
||
+ "la montre allumée et à portée."
|
||
|
||
case .watchNotFound(let seconds, let vues, _, let muettes) where muettes > 0:
|
||
// Le service est là mais ne répond pas : signature d'un canal déjà pris.
|
||
return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans "
|
||
+ "réponse en \(Int(seconds)) s. Le canal est probablement déjà "
|
||
+ "occupé : fermer complètement l'app Polar Flow (elle synchronise "
|
||
+ "en arrière-plan), puis réessayer."
|
||
|
||
case .watchNotFound(let seconds, let vues, _, _):
|
||
// Des appareils, mais aucun ne porte le service.
|
||
return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun "
|
||
+ "n'expose PsFTP. La Vantage n'annonce peut-être pas ce service "
|
||
+ "tant qu'elle est appairée à Flow — c'est l'inconnue restante."
|
||
}
|
||
}
|
||
}
|