43 appareils vus par un scan CoreBluetooth nu, 0 remonté par le SDK Polar. La
radio, l'autorisation et la montre sont hors de cause — c'est notre usage du SDK.
Trois suspects écartés sur les sources plutôt que supposés, et écrits dans le
runbook pour que personne ne les revérifie : servicesToScanFor à nil est aussi
ce que fait PolarBleApiImpl ; un scanPreFilter nil ACCEPTE les appareils (le
`if let` échoue, le rejet est sauté) ; et addClient() suffit à lancer le scan,
scanningNeeded() rendant vrai dès que clientCount != 0.
Reste une hypothèse : search() commence par
`monitorBleState().filter { $0 == .poweredOn }`. Si cet état n'arrive jamais, le
publisher ne dit RIEN — ni valeur, ni complétion, ni erreur — et le timeout
conclut « aucun appareil » alors qu'on n'a jamais cherché. Ce sont deux
diagnostics opposés que le même message couvrait.
L'instrumentation note donc si le publisher a parlé, et relaie une éventuelle
erreur du SDK telle quelle. Trois messages distincts en sortent, trois tests les
verrouillent.
Piste notée pour la reprise : le SDK pose powerStateObserver et
deviceSessionStateObserver sur son listener et passe identifier: 0 ; nous ne
posons aucun observateur et passons 1.
80 tests au vert sur tests-linux.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
8.0 KiB
Swift
174 lines
8.0 KiB
Swift
// PolarPftpStep.swift
|
||
// Une opération d'écriture PFTP, et les garde-fous qui empêchent de planter la
|
||
// montre. N'importe QUE Foundation, délibérément.
|
||
//
|
||
// POURQUOI CE FICHIER EST SÉPARÉ DU PLUGIN
|
||
//
|
||
// `CoachPolarBLE.swift` importe Capacitor et le SDK Polar : il ne peut être
|
||
// compilé que sur un Mac, dans Xcode. Or ce qui est ici est exactement la
|
||
// partie qu'il faut pouvoir vérifier — la sérialisation de l'en-tête, et les
|
||
// deux règles qui décident si une requête part ou non vers le firmware.
|
||
//
|
||
// Lié dans `tests-linux/Sources/CoachModel/` par un lien symbolique : les tests
|
||
// portent sur le fichier livré, pas sur une copie qui dériverait en silence.
|
||
//
|
||
// ⚠️ CE QUE CES GARDE-FOUS PROTÈGENT
|
||
//
|
||
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » a bloqué
|
||
// une Polar Vantage V3 : logo Polar puis écran noir, récupérée par un appui
|
||
// long sur OK (10-15 s). Le firmware ne renvoie pas d'erreur applicative sur
|
||
// une requête malformée — il s'effondre. Le Bluetooth utilise le même protocole
|
||
// PFTP que l'USB : changer de transport n'enlève rien au risque.
|
||
//
|
||
// Les mêmes règles existent dans `tools/polar/polar_ftp.py::pftp_put()` du
|
||
// dépôt coach_sportif. Les deux doivent dire la même chose.
|
||
|
||
import Foundation
|
||
|
||
/// Un PUT PFTP : un chemin, un contenu. Un contenu vide crée un dossier.
|
||
public struct PftpStep: Equatable {
|
||
public let path: String
|
||
public let data: Data
|
||
|
||
public init(path: String, data: Data) {
|
||
self.path = path
|
||
self.data = data
|
||
}
|
||
|
||
/// Un chemin terminé par « / » désigne un dossier, jamais un fichier.
|
||
public var isDirectory: Bool { path.hasSuffix("/") }
|
||
|
||
public var describe: String {
|
||
"\(isDirectory ? "mkdir" : "put ") \(path) (\(data.count) o)"
|
||
}
|
||
|
||
/// Refuse les formes qui ont fait, ou feraient, planter la montre.
|
||
///
|
||
/// Dernier rempart avant l'émission : à appeler à l'entrée du plugin ET
|
||
/// juste avant chaque écriture.
|
||
public func validate() throws {
|
||
if isDirectory && !data.isEmpty {
|
||
throw PolarPftpError.malformed(
|
||
"refus d'écrire \(data.count) octets vers « \(path) » : un chemin "
|
||
+ "terminé par « / » désigne un dossier. C'est cette requête exacte "
|
||
+ "qui a fait planter une Vantage V3 le 2026-08-17.")
|
||
}
|
||
if !isDirectory && data.isEmpty {
|
||
throw PolarPftpError.malformed(
|
||
"refus d'écrire un fichier vide vers « \(path) » : ajouter « / » "
|
||
+ "pour créer un dossier, ou fournir un contenu.")
|
||
}
|
||
if !path.hasPrefix("/U/0/") {
|
||
throw PolarPftpError.malformed(
|
||
"chemin « \(path) » hors de /U/0/ : refusé par précaution, rien "
|
||
+ "d'autre n'a jamais été écrit sur cette montre.")
|
||
}
|
||
}
|
||
|
||
/// `PbPFtpOperation { command = 1 (varint), path = 2 (string) }`.
|
||
///
|
||
/// Sérialisation identique à `encode_operation()` de `polar_ftp.py` :
|
||
/// `0x08` (champ 1, varint) + commande, puis `0x12` (champ 2, délimité) +
|
||
/// longueur + chemin. `PUT` vaut 1, d'après `pftp_request.proto` du SDK
|
||
/// (`enum Command { GET = 0; PUT = 1; MERGE = 2; REMOVE = 3; }`).
|
||
///
|
||
/// ⚠️ Pas de cadrage `[0x05, taille, taille]` ici : celui-là appartient au
|
||
/// transport série RFC76 de la version USB. En Bluetooth, le SDK cadre
|
||
/// lui-même — l'ajouter produirait une requête malformée, c'est-à-dire
|
||
/// exactement ce qui plante la montre.
|
||
public func header() -> Data {
|
||
var bytes = Data([0x08, 0x01, 0x12])
|
||
let path = Array(self.path.utf8)
|
||
bytes.append(contentsOf: PftpStep.varint(path.count))
|
||
bytes.append(contentsOf: path)
|
||
return bytes
|
||
}
|
||
|
||
/// Varint protobuf, 7 bits par octet, bit de poids fort = continuation.
|
||
public static func varint(_ value: Int) -> [UInt8] {
|
||
var n = value, out: [UInt8] = []
|
||
repeat {
|
||
var b = UInt8(n & 0x7F)
|
||
n >>= 7
|
||
if n > 0 { b |= 0x80 }
|
||
out.append(b)
|
||
} while n > 0
|
||
return out
|
||
}
|
||
}
|
||
|
||
public enum PolarPftpError: Error, CustomStringConvertible, Equatable {
|
||
case malformed(String)
|
||
/// `vues` : appareils BLE aperçus · `sansPsFtp` : sans le service FEEE ·
|
||
/// `muettes` : avec FEEE mais qui n'ont pas répondu.
|
||
case watchNotFound(Double, vues: Int, sansPsFtp: Int, muettes: Int)
|
||
case psftpUnavailable
|
||
/// La radio elle-même est inutilisable : autorisation, Bluetooth éteint,
|
||
/// matériel. Distinct de `watchNotFound` — ici on n'a même pas pu chercher.
|
||
case bluetoothUnusable(String)
|
||
/// Le SDK n'a remonté aucune session, mais CoreBluetooth, interrogé
|
||
/// directement, a vu `vusParCoreBluetooth` appareils. Deux diagnostics
|
||
/// opposés selon ce nombre.
|
||
case sdkSilencieux(vusParCoreBluetooth: Int,
|
||
publisherAParle: Bool = true,
|
||
erreurSdk: String? = nil)
|
||
|
||
/// ⚠️ Un échec de connexion a plusieurs causes OPPOSÉES, et un message
|
||
/// unique les confond — c'est ce qui s'est produit au premier essai du
|
||
/// 2026-08-31. Ne jamais fusionner ces branches.
|
||
public var description: String {
|
||
switch self {
|
||
case .malformed(let why):
|
||
return why
|
||
case .psftpUnavailable:
|
||
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
|
||
case .bluetoothUnusable(let why):
|
||
return why
|
||
|
||
case .sdkSilencieux(_, false, _):
|
||
// Le publisher n'a JAMAIS rien émis : `search()` s'arrête sur
|
||
// `monitorBleState().filter { $0 == .poweredOn }`. Le scan n'a donc
|
||
// pas démarré — ce n'est pas « aucun appareil », c'est « on n'a
|
||
// jamais cherché ».
|
||
return "le SDK Polar n'a jamais signalé que le Bluetooth était prêt : "
|
||
+ "son scan n'a pas démarré. Ce n'est pas la montre. "
|
||
+ "Piste : le listener du SDK n'obtient pas l'état poweredOn."
|
||
|
||
case .sdkSilencieux(_, _, .some(let erreur)):
|
||
return "le SDK Polar a répondu par une erreur : \(erreur)"
|
||
|
||
case .sdkSilencieux(0, _, _):
|
||
return "aucun appareil Bluetooth alentour, même en scan direct. "
|
||
+ "La radio fonctionne mais ne voit rien : montre éteinte, hors "
|
||
+ "de portée, ou déjà connectée à un autre appareil de façon "
|
||
+ "exclusive."
|
||
|
||
case .sdkSilencieux(let bruts, _, _):
|
||
return "\(bruts) appareil(s) Bluetooth vus en scan direct, mais le SDK "
|
||
+ "Polar n'en remonte aucun. La radio va bien : le problème est "
|
||
+ "dans l'intégration du SDK, pas dans la montre ni dans l'iPhone."
|
||
|
||
|
||
case .watchNotFound(let seconds, 0, _, _):
|
||
// Rien du tout : le problème est en amont de la montre.
|
||
return "aucun appareil Bluetooth détecté en \(Int(seconds)) s. "
|
||
+ "Vérifier, dans l'ordre : le Bluetooth activé sur l'iPhone ; "
|
||
+ "l'autorisation Bluetooth accordée à coach (Réglages → coach) ; "
|
||
+ "la montre allumée et à portée."
|
||
|
||
case .watchNotFound(let seconds, let vues, _, let muettes) where muettes > 0:
|
||
// Le service est là mais ne répond pas : signature d'un canal déjà pris.
|
||
return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans "
|
||
+ "réponse en \(Int(seconds)) s. Le canal est probablement déjà "
|
||
+ "occupé : fermer complètement l'app Polar Flow (elle synchronise "
|
||
+ "en arrière-plan), puis réessayer."
|
||
|
||
case .watchNotFound(let seconds, let vues, _, _):
|
||
// Des appareils, mais aucun ne porte le service.
|
||
return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun "
|
||
+ "n'expose PsFTP. La Vantage n'annonce peut-être pas ce service "
|
||
+ "tant qu'elle est appairée à Flow — c'est l'inconnue restante."
|
||
}
|
||
}
|
||
}
|