Sylvain enchaîne un troisième envoi et reçoit « le canal est probablement occupé » — notre propre message, déclenché par watchNotFound avec des montres muettes : le service PsFTP est là, il ne répond pas. La chronologie de ses trois envois explique tout. Le premier réussit, montre fraîchement réinitialisée donc pas encore liée à Flow. Le deuxième renvoie 104 DIRECTORY_EXISTS, ce qui prouve que le canal était ENCORE libre. Le troisième échoue : Flow a repris la main entre-temps. Le canal n'est donc pas ouvert ou fermé en général — il y a une fenêtre, qui se referme dès que Flow se reconnecte. Le message conseillait de fermer complètement l'app Polar Flow. C'était faux, et la mesure du 31/08 le disait déjà : 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. Le message nomme maintenant le geste qui libère réellement le canal — redémarrer la montre — précise que fermer l'app n'y change rien, et rappelle que le câble est le chemin fiable. Corrigé au passage le message des appareils sans PsFTP, qui présentait encore la question comme « l'inconnue restante » alors qu'elle a été tranchée le 31/08 : la montre expose bien PsFTP quand elle est connectée. Un test interdit désormais de la rouvrir dans l'UI. 88 tests Linux verts, dont deux nouveaux qui verrouillent l'absence du conseil inutile et la présence du geste utile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
12 KiB
Swift
247 lines
12 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
|
||
|
||
/// 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."
|
||
}
|
||
}
|
||
}
|