Sylvain a lancé l'envoi Bluetooth et obtenu « aucune montre Polar exposant PsFTP ». Ce message ne permet pas de conclure : il couvre à la fois « aucun appareil vu » (Bluetooth éteint, autorisation refusée à l'app, montre hors de portée) et « la montre est là mais ne répond pas » (canal déjà pris par Polar Flow). Ce sont des causes opposées et des gestes différents. Un essai qui n'apprend rien est un essai perdu, et celui-là demandait de brancher une montre. L'erreur porte désormais ce qui a été observé — appareils vus, combien sans le service FEEE, combien avec mais muets — et rend trois messages distincts, chacun nommant le geste correspondant. Quatre tests verrouillent la distinction, dont celui qui interdit d'accuser Polar Flow quand rien n'a été vu : ce serait envoyer sur une fausse piste. Le scan devient aussi répétitif, par fenêtres de 4 s jusqu'au timeout, au lieu d'une passe unique de 10 s. CoreBluetooth peut n'être pas encore poweredOn au premier appel : le scan ne démarre alors jamais et une fenêtre unique conclut à tort qu'aucun appareil n'existe. C'est une cause plausible du premier échec, et elle n'était pas couverte. 73 tests au vert sur tests-linux. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
6.1 KiB
Swift
139 lines
6.1 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
|
||
|
||
/// ⚠️ 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 .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."
|
||
}
|
||
}
|
||
}
|