« aucun appareil Bluetooth détecté » après 30 s de scan. La cause n'était pas la
montre : Réglages → coach ne contenait même pas de ligne Bluetooth. L'alerte
d'autorisation n'avait jamais été présentée.
La raison est dans le SDK. `CBDeviceListenerImpl.search()` commence par
`monitorBleState().filter { $0 == .poweredOn }` — tant que cet état n'arrive
pas, le flux n'émet rien et le scan ne démarre jamais. Or l'état ne peut venir
que d'un CBCentralManager, et c'est sa création qui déclenche la demande
d'autorisation. Aucun manager n'existant, on attendait un signal que rien ne
pouvait produire, puis on concluait « aucun appareil » au timeout. Avoir la clé
NSBluetoothAlwaysUsageDescription dans Info.plist ne suffit pas : elle fournit
le texte de l'alerte, elle ne la provoque pas.
`SondeBluetooth` crée donc le manager, attend son premier état non-`.unknown`
(l'état transitoire du démarrage, sur lequel il ne faut pas conclure), et
traduit ce qu'il dit : autorisation refusée, Bluetooth éteint, matériel absent.
Le cas d'erreur est distinct de watchNotFound — on n'a même pas pu chercher, et
parler de la montre envoyait chercher au mauvais endroit. Deux tests verrouillent
cette distinction.
Au premier lancement après ce correctif, iOS présentera l'alerte : il faut
l'accepter. Ensuite seulement on saura si la Vantage accepte une connexion
tierce, qui reste la question de fond.
75 tests au vert sur tests-linux.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.0 KiB
Swift
183 lines
8.0 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)
|
|
}
|
|
}
|