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 @@
../../../ios/App/App/PolarPftpStep.swift

View File

@@ -0,0 +1,120 @@
// Les garde-fous qui empêchent de bloquer une Polar Vantage V3.
//
// POURQUOI CES TESTS EXISTENT VRAIMENT
//
// Le 2026-08-17, une requête PFTP malformée 174 octets écrits vers un chemin
// terminé par « / », donc « du contenu dans un dossier » a bloqué la montre :
// logo Polar puis écran noir, récupérée par un appui long sur OK. Le firmware ne
// renvoie pas d'erreur applicative sur ce genre de requête, il s'effondre. Le
// Bluetooth utilise le même protocole que l'USB : changer de transport n'a rien
// enlevé au risque.
//
// Ces règles ne peuvent donc pas être vérifiées « à la relecture ». Elles sont
// la seule chose qui se dresse entre un bug de construction de chemin et une
// montre à plusieurs centaines de francs, et elles sont exécutées ici parce que
// le reste du plugin (Capacitor, PolarBleSdk) ne compile que sur un Mac.
//
// Les mêmes règles existent côté serveur dans
// `coach_sportif/tools/polar/polar_ftp.py::pftp_put()`. Si l'une des deux
// change, l'autre doit suivre.
import XCTest
@testable import CoachModel
final class PolarPftpStepTests: XCTestCase {
// MARK: - Les deux formes interdites
func testDossierAvecContenuEstRefuse() throws {
// La requête exacte qui a planté la montre le 2026-08-17.
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB/",
data: Data(repeating: 0x42, count: 174))
XCTAssertThrowsError(try etape.validate()) { erreur in
XCTAssertTrue("\(erreur)".contains("2026-08-17"),
"le message doit rappeler l'incident, pas seulement refuser")
}
}
func testFichierVideEstRefuse() {
// L'inverse : sans slash final la montre attend un fichier, et un
// fichier vide n'a aucun sens c'est un mkdir mal écrit.
let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB", data: Data())
XCTAssertThrowsError(try etape.validate())
}
func testCheminHorsDeUZeroEstRefuse() {
// Rien d'autre que /U/0/ n'a jamais été écrit sur cette montre : tout
// le reste est une exploration en écriture, donc un risque de blocage.
let etape = PftpStep(path: "/SYS/quelquechose.BPB", data: Data([1, 2, 3]))
XCTAssertThrowsError(try etape.validate())
}
// MARK: - Les deux formes valides
func testDossierSansContenuEstAccepte() throws {
try PftpStep(path: "/U/0/20260831/TST/", data: Data()).validate()
try PftpStep(path: "/U/0/20260831/TST/180000/", data: Data()).validate()
}
func testFichierAvecContenuEstAccepte() throws {
try PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB",
data: Data(repeating: 0x42, count: 240)).validate()
}
// MARK: - L'en-tête PbPFtpOperation
func testEnTeteReproduitLaSerialisationPython() {
// `encode_operation(PUT, path)` de polar_ftp.py :
// 0x08 (champ 1, varint) + 0x01 (PUT)
// 0x12 (champ 2, délimité) + longueur + chemin
let etape = PftpStep(path: "/U/0/", data: Data())
let attendu = Data([0x08, 0x01, 0x12, 0x05]) + Data("/U/0/".utf8)
XCTAssertEqual(etape.header(), attendu)
}
func testEnTeteAvecCheminReel() {
let chemin = "/U/0/20260831/TST/180000/TST.BPB"
let entete = PftpStep(path: chemin, data: Data([0x00])).header()
XCTAssertEqual(Array(entete.prefix(3)), [0x08, 0x01, 0x12])
XCTAssertEqual(entete[3], UInt8(chemin.utf8.count))
XCTAssertEqual(entete.suffix(chemin.utf8.count), Data(chemin.utf8))
}
func testEnTeteNePorteAucunCadrageSerie() {
// Le préfixe [0x05, taille, taille] appartient au transport RFC76 de la
// version USB. En Bluetooth, le SDK cadre lui-même : le reporter ici
// produirait une requête malformée c'est-à-dire ce qui plante la
// montre. Le premier octet doit être le tag protobuf, jamais 0x05.
let entete = PftpStep(path: "/U/0/test.BPB", data: Data([1])).header()
XCTAssertEqual(entete.first, 0x08)
}
// MARK: - Varint
func testVarintSurUnOctetEnDessousDe128() {
XCTAssertEqual(PftpStep.varint(0), [0x00])
XCTAssertEqual(PftpStep.varint(5), [0x05])
XCTAssertEqual(PftpStep.varint(127), [0x7F])
}
func testVarintPasseADeuxOctetsA128() {
// Un chemin de plus de 127 caractères existe : `/U/0/<date>/TST/<heure>/`
// plus un nom de fichier reste court, mais l'encodage doit être juste
// pour que la montre lise le bon nombre d'octets.
XCTAssertEqual(PftpStep.varint(128), [0x80, 0x01])
XCTAssertEqual(PftpStep.varint(300), [0xAC, 0x02])
}
func testEnTeteAvecCheminLongEncodeLaLongueurSurDeuxOctets() {
let chemin = "/U/0/" + String(repeating: "a", count: 200)
let entete = PftpStep(path: chemin, data: Data([1])).header()
XCTAssertEqual(Array(entete[3...4]), PftpStep.varint(chemin.utf8.count))
}
// MARK: - Description
func testLaDescriptionDistingueMkdirEtPut() {
XCTAssertTrue(PftpStep(path: "/U/0/x/", data: Data()).describe.hasPrefix("mkdir"))
XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put"))
}
}