Sylvain a rapporté un errorcode 104 en testant l'envoi. Le code vient du protocole PFTP de Polar, et pftp_error.proto du SDK officiel le nomme : 104 = DIRECTORY_EXISTS. Ce n'est donc pas un refus de la montre — la session BLE s'était parfaitement ouverte, le transport fonctionnait, et c'est notre séquence de mkdir qui n'était pas idempotente. L'hypothèse fautive était écrite noir sur blanc dans parseSteps : « aucun des deux n'existe d'avance pour une date neuve ». Vrai d'une date neuve, faux dès le second envoi vers la même date — exactement ce que Sylvain venait de faire après avoir réussi un premier envoi. Un mkdir qui bute sur 104 est désormais considéré comme satisfait, à la manière d'un mkdir -p : le dossier est là, c'est tout ce qu'on lui demandait. Strictement limité à la création de dossier — un put de fichier n'est jamais avalé, 105 FILE_EXISTS signifierait que l'objectif est déjà écrit et l'appelant doit le savoir. Le renvoi n'est pas passé sous silence pour autant : ecrire() rend un drapeau dossierPreexistant, le plugin le résout en alreadyExisted, et l'UI de coach affiche « Un objectif existait déjà à cette date. Redémarrer la montre pour que le nouveau s'affiche. » C'est la contrepartie de l'index en cache constaté le 31/08 — sans cet avertissement, l'envoi annoncerait un succès que la montre ne montrerait pas. Les codes PFTP sont définis dans PolarPftpStep.swift, donc couverts par les tests Linux : 87 tests verts, dont 3 nouveaux qui vérifient que 104 est satisfaisant pour un mkdir et que 103, 105, 106 et 108 restent des échecs. ⚠️ CoachPolarBLE.swift n'est pas compilable ici — Capacitor et le SDK Polar n'existent pas sur Linux. La modification du plugin est à vérifier au prochain build Xcode ; la logique des codes, elle, est testée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
227 lines
11 KiB
Swift
227 lines
11 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
|
||
|
||
/// Codes d'erreur du protocole PFTP, tels que Polar les publie.
|
||
///
|
||
/// Source : `pftp_error.proto` du SDK officiel — 100 `UNIDENTIFIED_HOST_ERROR`,
|
||
/// 101 `INVALID_COMMAND`, 102 `INVALID_PARAMETER`, 103 `NO_SUCH_FILE_OR_DIRECTORY`,
|
||
/// **104 `DIRECTORY_EXISTS`**, 105 `FILE_EXISTS`, 106 `OPERATION_NOT_PERMITTED`,
|
||
/// 107 `NO_SUCH_USER`, 108 `TIMEOUT`.
|
||
/// https://github.com/polarofficial/polar-ble-sdk/blob/master/sources/Android/android-communications/library/src/sdk/proto/pftp_error.proto
|
||
///
|
||
/// Le SDK iOS les remonte tels quels par
|
||
/// `BlePsFtpException.responseError(errorCode: Int)`.
|
||
public enum PftpCode {
|
||
/// Le dossier visé par un `mkdir` existe déjà.
|
||
///
|
||
/// ⚠️ **Ce n'est pas un échec d'envoi.** Rencontré le 01/09/2026 sur un
|
||
/// second envoi vers la même date : la session BLE s'était bien ouverte, et
|
||
/// c'est notre séquence de `mkdir` qui n'était pas idempotente. Le message
|
||
/// « errorcode 104 » donnait donc à croire à un refus de la montre alors
|
||
/// que le transport fonctionnait.
|
||
public static let directoryExists = 104
|
||
|
||
/// Codes sur lesquels un `mkdir` peut être considéré comme satisfait : le
|
||
/// dossier est là, c'est tout ce qu'on lui demandait. L'équivalent de
|
||
/// `mkdir -p`.
|
||
///
|
||
/// ⚠️ Strictement limité à la création de dossier. Un `put` de fichier ne
|
||
/// doit JAMAIS être avalé de la sorte — 105 `FILE_EXISTS` signifierait que
|
||
/// l'objectif est déjà écrit, ce que l'appelant doit savoir.
|
||
public static func mkdirEstSatisfait(par code: Int) -> Bool {
|
||
code == directoryExists
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
/// Une opération du SDK n'a pas rendu la main dans le délai imparti.
|
||
case echeanceDepassee(Double)
|
||
/// Le scan a vu des appareils, mais aucun ne ressemble à un Polar. Les
|
||
/// exemples servent à voir sous quel nom la montre s'annonce réellement.
|
||
case aucunPolarParmi(vues: Int, exemples: [String])
|
||
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 .aucunPolarParmi(let vues, let exemples):
|
||
return "\(vues) appareil(s) Bluetooth vus, aucun ne ressemble à une "
|
||
+ "montre Polar. Vus : \(exemples.joined(separator: ", ")). "
|
||
+ "Si la montre est dans cette liste sous un autre nom, c'est le "
|
||
+ "tri qui est trop strict ; si elle n'y est pas, elle ne "
|
||
+ "s'annonce pas — probablement parce qu'elle est déjà liée à "
|
||
+ "Polar Flow."
|
||
|
||
case .echeanceDepassee(let seconds):
|
||
return "la montre n'a pas répondu en \(Int(seconds)) s. Réveiller son "
|
||
+ "écran et la rapprocher de l'iPhone ; vérifier que Polar Flow "
|
||
+ "est bien fermée."
|
||
|
||
|
||
case .sdkSilencieux(_, false, _):
|
||
// ⚠️ Ne PAS conclure « le scan n'a pas démarré » : le publisher de
|
||
// `search()` reste également muet quand le scan tourne mais ne
|
||
// découvre rien — `scanSubject` n'émet que sur découverte, et le
|
||
// `prepend(knownSessions)` d'une liste vide n'émet pas. Une version
|
||
// précédente affirmait le contraire et a fait chercher au mauvais
|
||
// endroit pendant trois itérations.
|
||
return "le SDK Polar n'a remonté aucun appareil : soit son scan n'a "
|
||
+ "pas démarré, soit il tourne sans rien découvrir. L'état "
|
||
+ "Bluetooth, lui, a bien été atteint avant le scan."
|
||
|
||
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."
|
||
}
|
||
}
|
||
}
|