L'envoi tournait sans jamais rendre la main, ni message ni erreur. Cause : les `async` du SDK Polar n'ont aucune limite de temps, et `waitPsFtpReady` attend indéfiniment une montre qui ne finit pas sa négociation — canal déjà pris, écran éteint, appairage en cours. Une attente sans fin est pire qu'un échec : elle n'apprend rien, et c'est exactement ce qu'on cherche à éviter depuis ce matin. `waitPsFtpReady` a désormais 12 s, chaque écriture 20 s, et un dépassement compte comme une montre muette — c'est ce qu'elle est. Le message dit quoi faire : réveiller l'écran, rapprocher, fermer Flow. Le fait qu'on soit arrivé jusqu'à ce blocage est en soi une information : avant le réveil du manager lazy, on n'atteignait même pas la phase de connexion. 82 tests au vert sur tests-linux. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
11 KiB
Swift
261 lines
11 KiB
Swift
// 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"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Les trois causes d'échec ne doivent pas se confondre
|
|
//
|
|
// Au premier essai Bluetooth du 2026-08-31, un message unique disait « aucune
|
|
// montre exposant PsFTP » — sans distinguer « rien vu du tout » (Bluetooth
|
|
// éteint, permission refusée) de « la montre est là mais ne répond pas » (canal
|
|
// occupé par Polar Flow). Ce sont des causes opposées et des gestes différents.
|
|
|
|
final class PolarPftpErrorTests: XCTestCase {
|
|
|
|
func testAucunAppareilVuPointeVersLIPhone() {
|
|
let e = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0)
|
|
XCTAssertTrue(e.description.contains("autorisation Bluetooth"))
|
|
XCTAssertFalse(e.description.contains("Polar Flow"),
|
|
"sans aucun appareil vu, accuser Flow envoie sur une fausse piste")
|
|
}
|
|
|
|
func testUneMontreMuettePointeVersFlow() {
|
|
let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1)
|
|
XCTAssertTrue(e.description.contains("Polar Flow"))
|
|
XCTAssertTrue(e.description.contains("occupé"))
|
|
}
|
|
|
|
func testDesAppareilsSansPsFtpNommentLInconnueRestante() {
|
|
let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0)
|
|
XCTAssertTrue(e.description.contains("n'expose PsFTP"))
|
|
XCTAssertTrue(e.description.contains("4 appareil"))
|
|
}
|
|
|
|
func testLesTroisMessagesSontDistincts() {
|
|
let messages = Set([
|
|
PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description,
|
|
PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1).description,
|
|
PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0).description,
|
|
])
|
|
XCTAssertEqual(messages.count, 3)
|
|
}
|
|
}
|
|
|
|
// MARK: - « La radio ne marche pas » ≠ « la montre est introuvable »
|
|
//
|
|
// Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » — alors que
|
|
// la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation
|
|
// Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app.
|
|
// Un message parlant de la montre envoyait chercher au mauvais endroit.
|
|
|
|
extension PolarPftpErrorTests {
|
|
|
|
func testRadioInutilisableNeParlePasDeLaMontre() {
|
|
let e = PolarPftpError.bluetoothUnusable(
|
|
"coach n'a pas l'autorisation d'utiliser le Bluetooth.")
|
|
XCTAssertFalse(e.description.contains("montre"))
|
|
XCTAssertTrue(e.description.contains("autorisation"))
|
|
}
|
|
|
|
func testRadioInutilisableEstDistinctDeMontreIntrouvable() {
|
|
let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description
|
|
let montre = PolarPftpError.watchNotFound(30, vues: 0,
|
|
sansPsFtp: 0, muettes: 0).description
|
|
XCTAssertNotEqual(radio, montre)
|
|
}
|
|
}
|
|
|
|
// MARK: - Radio muette ou SDK muet : ce n'est pas le même diagnostic
|
|
//
|
|
// Le 2026-08-31, le SDK ne remontait aucune session et rien ne disait si
|
|
// CoreBluetooth lui-même voyait quelque chose. Sans cette mesure, impossible de
|
|
// savoir s'il fallait chercher du côté de la montre ou de notre code.
|
|
|
|
extension PolarPftpErrorTests {
|
|
|
|
func testAucunAppareilEnScanDirectAccuseLEnvironnement() {
|
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 0)
|
|
XCTAssertTrue(e.description.contains("hors\u{00A0}de portée")
|
|
|| e.description.contains("hors de portée"))
|
|
XCTAssertFalse(e.description.contains("intégration"))
|
|
}
|
|
|
|
func testDesAppareilsEnScanDirectAccusentLeSDK() {
|
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 12)
|
|
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
|
XCTAssertTrue(e.description.contains("12"))
|
|
XCTAssertTrue(e.description.contains("La radio va bien"))
|
|
}
|
|
}
|
|
|
|
// MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé »
|
|
//
|
|
// `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`.
|
|
// Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni
|
|
// erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a
|
|
// jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le
|
|
// SDK.
|
|
|
|
extension PolarPftpErrorTests {
|
|
|
|
func testPublisherMuetNAccusePasLaMontre() {
|
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
|
publisherAParle: false)
|
|
XCTAssertTrue(e.description.contains("n'a pas démarré"))
|
|
XCTAssertTrue(e.description.contains("Ce n'est pas la montre"))
|
|
}
|
|
|
|
func testUneErreurDuSdkEstRapporteeTelleQuelle() {
|
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
|
publisherAParle: true,
|
|
erreurSdk: "bleNotReady")
|
|
XCTAssertTrue(e.description.contains("bleNotReady"))
|
|
}
|
|
|
|
func testPublisherActifSansSessionAccuseLIntegration() {
|
|
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
|
|
publisherAParle: true)
|
|
XCTAssertTrue(e.description.contains("intégration du SDK"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Un envoi qui n'aboutit pas doit le dire
|
|
//
|
|
// Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi
|
|
// est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne
|
|
// finissait pas la négociation. Une attente sans fin est pire qu'une erreur —
|
|
// elle ne dit rien et n'apprend rien.
|
|
|
|
extension PolarPftpErrorTests {
|
|
|
|
func testLEcheanceDitQuoiFaire() {
|
|
let e = PolarPftpError.echeanceDepassee(12)
|
|
XCTAssertTrue(e.description.contains("12"))
|
|
XCTAssertTrue(e.description.contains("écran"))
|
|
XCTAssertTrue(e.description.contains("Polar Flow"))
|
|
}
|
|
|
|
func testLEcheanceNeSeConfondPasAvecUnScanVide() {
|
|
let echeance = PolarPftpError.echeanceDepassee(12).description
|
|
let vide = PolarPftpError.watchNotFound(30, vues: 0,
|
|
sansPsFtp: 0, muettes: 0).description
|
|
XCTAssertNotEqual(echeance, vide)
|
|
}
|
|
}
|