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:
1
tests-linux/Sources/CoachModel/PolarPftpStep.swift
Symbolic link
1
tests-linux/Sources/CoachModel/PolarPftpStep.swift
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ios/App/App/PolarPftpStep.swift
|
||||
120
tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Normal file
120
tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user