// 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 /// Codes d'erreur du protocole PFTP, tels que Polar les publie. /// /// Source : `pftp_error.proto` du SDK officiel — 100 `UNIDENTIFIED_HOST_ERROR`, /// 101 `INVALID_COMMAND`, 102 `INVALID_PARAMETER`, 103 `NO_SUCH_FILE_OR_DIRECTORY`, /// **104 `DIRECTORY_EXISTS`**, 105 `FILE_EXISTS`, 106 `OPERATION_NOT_PERMITTED`, /// 107 `NO_SUCH_USER`, 108 `TIMEOUT`. /// https://github.com/polarofficial/polar-ble-sdk/blob/master/sources/Android/android-communications/library/src/sdk/proto/pftp_error.proto /// /// Le SDK iOS les remonte tels quels par /// `BlePsFtpException.responseError(errorCode: Int)`. public enum PftpCode { /// Le dossier visé par un `mkdir` existe déjà. /// /// ⚠️ **Ce n'est pas un échec d'envoi.** Rencontré le 01/09/2026 sur un /// second envoi vers la même date : la session BLE s'était bien ouverte, et /// c'est notre séquence de `mkdir` qui n'était pas idempotente. Le message /// « errorcode 104 » donnait donc à croire à un refus de la montre alors /// que le transport fonctionnait. public static let directoryExists = 104 /// Codes sur lesquels un `mkdir` peut être considéré comme satisfait : le /// dossier est là, c'est tout ce qu'on lui demandait. L'équivalent de /// `mkdir -p`. /// /// ⚠️ Strictement limité à la création de dossier. Un `put` de fichier ne /// doit JAMAIS être avalé de la sorte — 105 `FILE_EXISTS` signifierait que /// l'objectif est déjà écrit, ce que l'appelant doit savoir. public static func mkdirEstSatisfait(par code: Int) -> Bool { code == directoryExists } } /// 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. /// Une opération du SDK n'a pas rendu la main dans le délai imparti. case echeanceDepassee(Double) /// Le scan a vu des appareils, mais aucun ne ressemble à un Polar. Les /// exemples servent à voir sous quel nom la montre s'annonce réellement. case aucunPolarParmi(vues: Int, exemples: [String]) 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 .aucunPolarParmi(let vues, let exemples): return "\(vues) appareil(s) Bluetooth vus, aucun ne ressemble à une " + "montre Polar. Vus : \(exemples.joined(separator: ", ")). " + "Si la montre est dans cette liste sous un autre nom, c'est le " + "tri qui est trop strict ; si elle n'y est pas, elle ne " + "s'annonce pas — probablement parce qu'elle est déjà liée à " + "Polar Flow." case .echeanceDepassee(let seconds): return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son " + "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow " + "est bien fermée." case .sdkSilencieux(_, false, _): // ⚠️ Ne PAS conclure « le scan n'a pas démarré » : le publisher de // `search()` reste également muet quand le scan tourne mais ne // découvre rien — `scanSubject` n'émet que sur découverte, et le // `prepend(knownSessions)` d'une liste vide n'émet pas. Une version // précédente affirmait le contraire et a fait chercher au mauvais // endroit pendant trois itérations. return "le SDK Polar n'a remonté aucun appareil : soit son scan n'a " + "pas démarré, soit il tourne sans rien découvrir. L'état " + "Bluetooth, lui, a bien été atteint avant le scan." 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 : canal déjà pris. // // ⚠️ Ce message conseillait « fermer l'app Polar Flow ». C'était // faux, et mesuré comme tel le 31/08/2026 : la montre affichait // « connexion impossible » pendant que les réglages iOS la // disaient TOUJOURS connectée à Flow. iOS maintient le lien d'un // accessoire appairé, app fermée ou non — fermer l'app ne rend pas // le canal. // // Ce qui l'a rendu, le 01/09 : une montre fraîchement // réinitialisée, donc pas encore liée à Flow. Redémarrer la montre // libère aussi son canal, mais Flow le reprend. return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans " + "réponse en \(Int(seconds)) s. Le canal PsFTP de la Vantage " + "n'accepte qu'une session, et Polar Flow la détient tant que " + "la montre lui est appairée — fermer l'app n'y change rien, " + "iOS maintient le lien. Redémarrer la montre (appui long sur " + "OK) libère le canal ; il faut écrire dans la foulée, avant " + "que Flow ne le reprenne. Chemin fiable : le câble." case .watchNotFound(let seconds, let vues, _, _): // Des appareils, mais aucun ne porte le service. // ⚠️ Ce message parlait d'« inconnue restante ». Elle a été levée le // 31/08/2026 : la montre EST atteignable et expose bien PsFTP // (« 12 appareils vus, 1 portant PsFTP ») — quand elle est // connectée. N'en voir aucun est donc autre chose : montre éteinte, // hors de portée, ou pas encore connectée à l'iPhone. return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun " + "n'expose PsFTP. La Vantage expose bien ce service quand elle " + "est connectée (vérifié le 31/08) : la chercher plutôt du côté " + "de la montre — allumée, à portée, et reliée à l'iPhone." } } }