La séance part sur la Polar en Bluetooth, et refuse de partir mal formée

Contrainte posée aujourd'hui : coach → la montre, sans TrainingPeaks, sans
Polar Flow, sans Intervals.icu. Toutes les autres voies passent par un tiers ;
celle-ci est la seule qui reste, et elle était déjà prouvée en USB le 17/08.
Manquait le transport qui la rende utilisable — brancher la montre au Mac cinq
minutes avant de sortir n'est pas un usage.

`CoachPolarBLE` ne fait que transporter. Les octets viennent du serveur
(GET /api/plan/polar-target), produits par un encodeur verrouillé octet pour
octet contre des fichiers relus sur la montre. Rien n'est réencodé ici : le
faire perdrait la seule garantie que le firmware acceptera le fichier.

La chaîne SDK a été vérifiée sur les sources avant d'écrire une ligne, et elle
est entièrement publique — CBDeviceListenerImpl, search, openSessionDirect,
fetchGattClient(PSFTP_SERVICE), waitPsFtpReady, write(header:data:). Ni fork,
ni symbole interne.

Le morceau qui compte vraiment est ailleurs. `PolarPftpStep` est séparé du
plugin et n'importe que Foundation, pour être exécuté sur Linux : il porte
l'en-tête PbPFtpOperation et les deux règles qui décident si une requête part.
Le 17/08, un PUT de 174 octets vers un chemin terminé par « / » a bloqué la
montre — écran noir, appui long sur OK pour la récupérer. Le firmware ne refuse
pas, il s'effondre, et le Bluetooth utilise le même protocole. Ces règles ne
peuvent donc pas être vérifiées « à la relecture » : 12 tests les exécutent,
dont celui qui interdit de reporter ici le cadrage série [0x05, taille, taille]
de la version USB — en BLE c'est le SDK qui cadre, l'ajouter produirait
précisément une requête malformée.

L'en-tête produit par Swift a été comparé à celui de polar_ftp.py sur trois
chemins, dont un de 205 caractères pour le varint sur deux octets : mêmes
octets. 69 tests au vert sur tests-linux.

Un mode dryRun construit et décrit chaque requête sans rien émettre — le
--dry-run qui manquait le jour du plantage. Le runbook Mac dit de s'en servir au
premier essai, et liste les trois inconnues qui ne se lèveront que montre en
main, la première étant : la V3 accepte-t-elle une connexion BLE tierce pendant
qu'elle est appairée à Flow ?

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-31 07:06:53 +00:00
parent a44fa0046a
commit 184c08a447
7 changed files with 607 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
// 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)
case watchNotFound(Double)
case psftpUnavailable
public var description: String {
switch self {
case .malformed(let why): return why
case .watchNotFound(let seconds):
return "aucune montre Polar exposant PsFTP trouvée en \(Int(seconds)) s. "
+ "Vérifier que la montre est allumée, à portée, et que l'app Polar "
+ "Flow n'est pas en train de synchroniser — le canal BLE ne se "
+ "partage pas."
case .psftpUnavailable:
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
}
}
}