// 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) /// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé /// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics /// opposés selon ce nombre. case sdkSilencieux(vusParCoreBluetooth: Int, publisherAParle: Bool = true, erreurSdk: String? = nil) /// ⚠️ 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 .sdkSilencieux(_, false, _): // Le publisher n'a JAMAIS rien émis : `search()` s'arrête sur // `monitorBleState().filter { $0 == .poweredOn }`. Le scan n'a donc // pas démarré — ce n'est pas « aucun appareil », c'est « on n'a // jamais cherché ». return "le SDK Polar n'a jamais signalé que le Bluetooth était prêt : " + "son scan n'a pas démarré. Ce n'est pas la montre. " + "Piste : le listener du SDK n'obtient pas l'état poweredOn." case .sdkSilencieux(_, _, .some(let erreur)): return "le SDK Polar a répondu par une erreur : \(erreur)" case .sdkSilencieux(0, _, _): return "aucun appareil Bluetooth alentour, même en scan direct. " + "La radio fonctionne mais ne voit rien : montre éteinte, hors " + "de portée, ou déjà connectée à un autre appareil de façon " + "exclusive." case .sdkSilencieux(let bruts, _, _): return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK " + "Polar n'en remonte aucun. La radio va bien : le problème est " + "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone." 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." } } }