Autorisation Bluetooth accordée, alerte acceptée — et toujours zéro appareil. Or un scan BLE en environnement normal voit toujours quelque chose : le problème n'est donc pas la montre. Restait à savoir s'il venait de la radio ou de notre usage du SDK, et rien dans le message ne permettait de trancher. J'ai vérifié dans les sources ce qui pouvait faire taire le scan côté SDK, et écarté deux hypothèses plutôt que de les supposer : `servicesToScanFor` est nil chez eux aussi, et un `scanPreFilter` nil ACCEPTE les appareils découverts (`if …, let filter = self.scanPreFilter` — la condition échoue, le rejet est sauté). Ni l'un ni l'autre n'explique le silence. Donc on mesure. Quand le SDK ne remonte aucune session, la sonde relance un scan CoreBluetooth NU, sans le SDK, et le nombre part dans le message : - 0 appareil en scan direct → la radio va bien mais ne voit rien : montre éteinte, hors de portée, ou connectée ailleurs de façon exclusive. - N appareils en scan direct → la radio va bien, le problème est dans notre intégration du SDK, ni dans la montre ni dans l'iPhone. Ce sont deux chantiers opposés, et deviner lequel a déjà coûté trois allers-retours aujourd'hui. Deux tests verrouillent la distinction. 77 tests au vert sur tests-linux. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
8.9 KiB
Swift
206 lines
8.9 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"))
|
|
}
|
|
}
|