Files
coach-ios/ios/App/App/PolarPftpStep.swift
Sylvain Bettinelli 422b6ac3e6 « Fermer l'app Polar Flow » était un conseil faux, et mesuré tel dès le 31/08
Sylvain enchaîne un troisième envoi et reçoit « le canal est probablement
occupé » — notre propre message, déclenché par watchNotFound avec des montres
muettes : le service PsFTP est là, il ne répond pas.

La chronologie de ses trois envois explique tout. Le premier réussit, montre
fraîchement réinitialisée donc pas encore liée à Flow. Le deuxième renvoie 104
DIRECTORY_EXISTS, ce qui prouve que le canal était ENCORE libre. Le troisième
échoue : Flow a repris la main entre-temps. Le canal n'est donc pas ouvert ou
fermé en général — il y a une fenêtre, qui se referme dès que Flow se
reconnecte.

Le message conseillait de fermer complètement l'app Polar Flow. C'était faux, et
la mesure du 31/08 le disait déjà : la montre affichait « connexion impossible »
pendant que les réglages iOS la disaient toujours connectée à Flow. iOS maintient
le lien d'un accessoire appairé, app fermée ou non. Le message nomme maintenant
le geste qui libère réellement le canal — redémarrer la montre — précise que
fermer l'app n'y change rien, et rappelle que le câble est le chemin fiable.

Corrigé au passage le message des appareils sans PsFTP, qui présentait encore la
question comme « l'inconnue restante » alors qu'elle a été tranchée le 31/08 :
la montre expose bien PsFTP quand elle est connectée. Un test interdit désormais
de la rouvrir dans l'UI.

88 tests Linux verts, dont deux nouveaux qui verrouillent l'absence du conseil
inutile et la présence du geste utile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:30:48 +00:00

247 lines
12 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 : canal déjà pris.
//
// Ce message conseillait « fermer l'app Polar Flow ». C'était
// faux, et mesuré comme tel le 31/08/2026 : la montre affichait
// « connexion impossible » pendant que les réglages iOS la
// disaient TOUJOURS connectée à Flow. iOS maintient le lien d'un
// accessoire appairé, app fermée ou non fermer l'app ne rend pas
// le canal.
//
// Ce qui l'a rendu, le 01/09 : une montre fraîchement
// réinitialisée, donc pas encore liée à Flow. Redémarrer la montre
// libère aussi son canal, mais Flow le reprend.
return "\(vues) appareil(s) vu(s), \(muettes) portant PsFTP mais sans "
+ "réponse en \(Int(seconds)) s. Le canal PsFTP de la Vantage "
+ "n'accepte qu'une session, et Polar Flow la détient tant que "
+ "la montre lui est appairée — fermer l'app n'y change rien, "
+ "iOS maintient le lien. Redémarrer la montre (appui long sur "
+ "OK) libère le canal ; il faut écrire dans la foulée, avant "
+ "que Flow ne le reprenne. Chemin fiable : le câble."
case .watchNotFound(let seconds, let vues, _, _):
// Des appareils, mais aucun ne porte le service.
// Ce message parlait d'« inconnue restante ». Elle a été levée le
// 31/08/2026 : la montre EST atteignable et expose bien PsFTP
// (« 12 appareils vus, 1 portant PsFTP ») quand elle est
// connectée. N'en voir aucun est donc autre chose : montre éteinte,
// hors de portée, ou pas encore connectée à l'iPhone.
return "\(vues) appareil(s) Bluetooth vu(s) en \(Int(seconds)) s, aucun "
+ "n'expose PsFTP. La Vantage expose bien ce service quand elle "
+ "est connectée (vérifié le 31/08) : la chercher plutôt du côté "
+ "de la montre — allumée, à portée, et reliée à l'iPhone."
}
}
}