La séance part sur la Polar en Bluetooth, et refuse de partir mal formée

Contrainte posée aujourd'hui : coach → la montre, sans TrainingPeaks, sans
Polar Flow, sans Intervals.icu. Toutes les autres voies passent par un tiers ;
celle-ci est la seule qui reste, et elle était déjà prouvée en USB le 17/08.
Manquait le transport qui la rende utilisable — brancher la montre au Mac cinq
minutes avant de sortir n'est pas un usage.

`CoachPolarBLE` ne fait que transporter. Les octets viennent du serveur
(GET /api/plan/polar-target), produits par un encodeur verrouillé octet pour
octet contre des fichiers relus sur la montre. Rien n'est réencodé ici : le
faire perdrait la seule garantie que le firmware acceptera le fichier.

La chaîne SDK a été vérifiée sur les sources avant d'écrire une ligne, et elle
est entièrement publique — CBDeviceListenerImpl, search, openSessionDirect,
fetchGattClient(PSFTP_SERVICE), waitPsFtpReady, write(header:data:). Ni fork,
ni symbole interne.

Le morceau qui compte vraiment est ailleurs. `PolarPftpStep` est séparé du
plugin et n'importe que Foundation, pour être exécuté sur Linux : il porte
l'en-tête PbPFtpOperation et les deux règles qui décident si une requête part.
Le 17/08, un PUT de 174 octets vers un chemin terminé par « / » a bloqué la
montre — écran noir, appui long sur OK pour la récupérer. Le firmware ne refuse
pas, il s'effondre, et le Bluetooth utilise le même protocole. Ces règles ne
peuvent donc pas être vérifiées « à la relecture » : 12 tests les exécutent,
dont celui qui interdit de reporter ici le cadrage série [0x05, taille, taille]
de la version USB — en BLE c'est le SDK qui cadre, l'ajouter produirait
précisément une requête malformée.

L'en-tête produit par Swift a été comparé à celui de polar_ftp.py sur trois
chemins, dont un de 205 caractères pour le varint sur deux octets : mêmes
octets. 69 tests au vert sur tests-linux.

Un mode dryRun construit et décrit chaque requête sans rien émettre — le
--dry-run qui manquait le jour du plantage. Le runbook Mac dit de s'en servir au
premier essai, et liste les trois inconnues qui ne se lèveront que montre en
main, la première étant : la V3 accepte-t-elle une connexion BLE tierce pendant
qu'elle est appairée à Flow ?

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-31 07:06:53 +00:00
parent a44fa0046a
commit 184c08a447
7 changed files with 607 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# Écrire la séance sur la Polar en Bluetooth — ce qui reste à faire sur le Mac
Le code est écrit et poussé. Ce qui suit ne peut se faire que sur le Mac mini,
Xcode ouvert, montre à portée. À lire en entier avant le premier envoi : la
première tentative est celle qui peut bloquer la montre.
## Pourquoi ce chemin, et pas un autre
Contrainte posée le 2026-08-31 : **`coach → la montre`, sans intermédiaire.**
Pas de TrainingPeaks, pas de Polar Flow, pas d'Intervals.icu. Ça élimine toutes
les autres voies, qui passent toutes par un tiers. Reste l'écriture directe du
fichier d'objectif sur la montre par PFTP — prouvée sur la Vantage V3 le
2026-08-17, en USB. Le Bluetooth est ce qui la rend utilisable.
Le détail de ce qui a été mesuré sur la montre est dans le dépôt `coach_sportif`,
`tools/polar/README.md`. Il fait foi.
## 1. Ajouter le SDK Polar au projet
⚠️ **Pas dans `ios/App/CapApp-SPM/Package.swift`** : ce fichier porte
« DO NOT MODIFY — managed by Capacitor CLI » et sera réécrit. Ajouter le paquet
au `.xcodeproj` :
Xcode → File → Add Package Dependencies…
https://github.com/polarofficial/polar-ble-sdk
→ produit « PolarBleSdk », cible « App »
Tant que le paquet n'est pas là, `CoachPolarBLE.isAvailable()` répond
`{available: false, reason: "PolarBleSdk absent de la cible"}` — le fichier
compile quand même, tout le code radio est derrière `#if canImport(PolarBleSdk)`.
## 2. Target Membership
Deux fichiers, cible **App** :
- `ios/App/App/CoachPolarBLE.swift` — le plugin
- `ios/App/App/PolarPftpStep.swift` — les garde-fous et l'en-tête PFTP
Le second n'importe que Foundation : il est aussi lié dans
`tests-linux/Sources/CoachModel/` et couvert par 12 tests qui tournent sur
Linux (`./tests-linux/run.sh`).
## 3. Permission Bluetooth
`NSBluetoothAlwaysUsageDescription` est déjà dans `Info.plist`, en français.
iOS demandera l'autorisation au premier scan.
## 4. Le premier envoi — en `dryRun`, toujours
Depuis la console Safari attachée à la WebView :
```js
const r = await fetch('/api/plan/polar-target?date=2026-08-31&hour=18')
const plan = await r.json()
await window.Capacitor.Plugins.CoachPolarBLE.send({ ...plan, dryRun: true })
```
Attendu :
```
{ sent: false, dryRun: true, log: [
"mkdir /U/0/20260831/TST/ (0 o)",
"mkdir /U/0/20260831/TST/180000/ (0 o)",
"put /U/0/20260831/TST/180000/TST.BPB (240 o)",
"put /U/0/20260831/TST/180000/ID.BPB (51 o)" ] }
```
**Lire ce journal avant d'ôter `dryRun`.** Un `mkdir` avec des octets, ou un
`put` vers un chemin terminé par « / », est la requête exacte qui a bloqué la
montre le 2026-08-17 — le plugin la refuse, mais la voir refusée vaut mieux que
de la découvrir en radio.
## 5. L'envoi réel
Retirer `dryRun`. Fermer l'app Polar Flow d'abord : **le canal BLE ne se
partage pas**, et une synchro Flow en cours empêchera la connexion.
En cas d'échec, le message distingue les cas — montre introuvable, PsFTP
muet, requête refusée. Ce ne sont pas les mêmes causes.
## Ce qui reste inconnu, et ne se lèvera qu'ici
1. **La V3 accepte-t-elle une connexion BLE tierce** alors qu'elle est appairée
à l'app Flow ? Jamais testé. C'est le risque principal.
2. **Le filtrage de la montre** se fait sur « expose le service PsFTP (FEEE) »,
pas sur le nom : ce que la V3 met dans son advertisement n'a pas été observé,
et s'appuyer dessus aurait été une supposition. À resserrer une fois vu. Un
capteur H10 s'écarte de lui-même, il ne porte pas PsFTP.
3. **Le firmware plante sur requête malformée.** Les garde-fous couvrent les
formes connues ; ils ne prouvent pas qu'il n'en existe pas d'autres.
## Si la montre se bloque
Logo Polar puis écran noir. **Appui long sur OK, 10-15 s.** À défaut,
BACK + DOWN pendant 10 s. En dernier recours, réinitialisation d'usine par
FlowSync — les données déjà synchronisées dans Flow sont préservées.
## Après l'envoi
L'objectif est **éphémère** : une synchro Flow supprime ce qui est déposé à une
date qu'elle ignore (mesuré). C'est pour ça que l'envoi se fait juste avant de
sortir, et c'est pour ça que le Bluetooth était la condition — brancher la
montre au Mac cinq minutes avant de courir n'était pas un usage.
⚠️ Les cibles écrites sont des **numéros de zone**, pas des battements : la
montre les exécute selon les zones réglées dans Flow. Vérifiées alignées le
2026-08-17, à 1 bpm près sur la frontière Z2/Z3. La réponse du serveur porte les
bornes de `current_zones()` pour ce contrôle — les afficher avant l'envoi.

View File

@@ -0,0 +1,254 @@
// CoachPolarBLE.swift
// Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud
// ni compte ni API tierce.
//
// POURQUOI CE PLUGIN EXISTE
//
// L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée
// depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle
// ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq
// minutes avant de sortir ce qui n'est pas un usage.
//
// Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin
// utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le
// 2026-08-31 `coach la montre`, sans TrainingPeaks, sans Polar Flow, sans
// Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre.
//
// CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS
//
// Il transporte. Rien d'autre. Les octets viennent du serveur
// (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé
// octet pour octet contre des fichiers relus sur la montre. **Ne jamais
// réencoder un objectif ici** : ce serait perdre la seule garantie dont on
// dispose que le firmware acceptera le fichier.
//
// LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE
//
// Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » donc
// « écrire du contenu dans un dossier » a bloqué une Vantage V3 : logo Polar
// puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse
// pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le
// risque est identique ici.
//
// D'où deux verrous, repris de `tools/polar/polar_ftp.py` :
// - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ;
// - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide.
// Et un mode `dryRun` qui construit chaque requête et la décrit sans rien
// émettre l'équivalent du `--dry-run` qui a manqué le jour du plantage.
//
// CHAÎNE SDK vérifiée sur les sources le 2026-08-31, tout est public
//
// CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central
// listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher<BleDeviceSession, Error>
// listener.openSessionDirect(_:)
// session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE")
// client.waitPsFtpReady(_:) async throws
// client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream<UInt, Error>
//
// Aucun fork, aucun symbole interne.
//
// Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA
// PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est
// le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les
// données brutes. Reporter le cadrage produirait une requête malformée c'est-
// à-dire précisément ce qui plante la montre.
//
// USAGE CÔTÉ JS
//
// await window.Capacitor.Plugins.CoachPolarBLE.isAvailable()
// { available: bool }
//
// await window.Capacitor.Plugins.CoachPolarBLE.send({
// mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'],
// dir: '/U/0/20260831/TST/180000/',
// files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }],
// dryRun: true, // par défaut FALSE, mais à utiliser au premier essai
// timeoutSec: 30,
// })
// { sent: bool, dryRun: bool, log: [string] }
//
// Le corps de `send` est exactement la réponse de `/api/plan/polar-target` :
// le JS n'a rien à recomposer.
import Foundation
import Capacitor
#if canImport(PolarBleSdk)
import PolarBleSdk
import CoreBluetooth
import Combine
#endif
@objc(CoachPolarBLEPlugin)
public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
// CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8
// n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE`
// reste undefined (même piège que CoachWorkoutKit).
public let identifier = "CoachPolarBLEPlugin"
public let jsName = "CoachPolarBLE"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
]
@objc func isAvailable(_ call: CAPPluginCall) {
#if canImport(PolarBleSdk)
call.resolve(["available": true])
#else
call.resolve(["available": false,
"reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"])
#endif
}
@objc func send(_ call: CAPPluginCall) {
guard let etapes = Self.parseSteps(call) else {
call.reject("payload invalide : `dir`, `files[]` (name + b64) requis")
return
}
let dryRun = call.getBool("dryRun") ?? false
let timeout = call.getDouble("timeoutSec") ?? 30
// Les verrous d'abord, la radio ensuite. Une requête refusée ici est une
// montre qui ne plante pas.
do {
try etapes.forEach { try $0.validate() }
} catch {
call.reject("\(error)")
return
}
if dryRun {
call.resolve(["sent": false, "dryRun": true,
"log": etapes.map { $0.describe }])
return
}
#if canImport(PolarBleSdk)
Task {
do {
let journal = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
call.resolve(["sent": true, "dryRun": false, "log": journal])
} catch {
call.reject("\(error)")
}
}
#else
call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode")
#endif
}
/// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir.
static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? {
guard let dir = call.getString("dir"), !dir.isEmpty else { return nil }
var etapes: [PftpStep] = []
// Les dossiers d'abord, du parent vers l'enfant : `/U/0/<date>/TST/` puis
// `<heure>/`. Aucun des deux n'existe d'avance pour une date neuve, et un
// mkdir dans le désordre échoue.
for chemin in call.getArray("mkdir", String.self) ?? [] {
etapes.append(PftpStep(path: chemin, data: Data()))
}
guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else {
return nil
}
for fichier in fichiers {
guard let nom = fichier["name"] as? String,
let b64 = fichier["b64"] as? String,
let octets = Data(base64Encoded: b64) else { return nil }
etapes.append(PftpStep(path: dir + nom, data: octets))
}
return etapes
}
}
// MARK: - Le transport
#if canImport(PolarBleSdk)
/// Ouvre une session PsFTP sur la première montre qui répond, et écrit.
///
/// Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant
/// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et
/// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu
/// en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de
/// lui-même.
final class PolarPsFtpWriter {
private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble")
private var abonnements = Set<AnyCancellable>()
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] {
let listener = CBDeviceListenerImpl(
queue,
clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }],
identifier: 1)
let (client, session) = try await trouverClient(listener, timeout: timeout)
defer { listener.closeSessionDirect(session) }
var journal: [String] = []
for etape in etapes {
// Revalidé juste avant l'émission : entre la validation d'entrée et
// ce point, rien ne doit avoir introduit un chemin de dossier avec
// du contenu.
try etape.validate()
try await put(client, etape)
journal.append("" + etape.describe)
}
return journal
}
private func trouverClient(_ listener: CBDeviceListenerImpl,
timeout: Double) async throws
-> (BlePsFtpClient, BleDeviceSession) {
let debut = Date()
let sessions = try await sessionsVues(listener, timeout: timeout)
for session in sessions {
if Date().timeIntervalSince(debut) > timeout { break }
listener.openSessionDirect(session)
guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE)
as? BlePsFtpClient else {
listener.closeSessionDirect(session)
continue
}
do {
try await client.waitPsFtpReady(true)
return (client, session)
} catch {
listener.closeSessionDirect(session)
}
}
throw PolarPftpError.watchNotFound(timeout)
}
/// Les appareils vus pendant la fenêtre de recherche, appairés compris.
private func sessionsVues(_ listener: CBDeviceListenerImpl,
timeout: Double) async throws -> [BleDeviceSession] {
try await withCheckedThrowingContinuation { suite in
var vues: [BleDeviceSession] = []
var rendu = false
let rendre = {
guard !rendu else { return }
rendu = true
suite.resume(returning: vues)
}
listener.search(nil, identifiers: nil, fetchKnownDevices: true)
.sink(receiveCompletion: { _ in rendre() },
receiveValue: { vues.append($0) })
.store(in: &abonnements)
// La recherche ne se termine pas d'elle-même : on lui donne une
// fenêtre, puis on travaille avec ce qu'on a vu.
queue.asyncAfter(deadline: .now() + min(timeout, 10)) { rendre() }
}
}
private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws {
let entree = InputStream(data: etape.data)
// `write` rend un flux de progression ; on le consomme jusqu'au bout,
// et l'absence d'erreur vaut acquittement c'est l'équivalent BLE du
// `05 00 00` observé en USB.
for try await _ in client.write(etape.header() as NSData, data: entree) {}
}
}
#endif

View File

@@ -26,6 +26,8 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Coach Hypnotruck se connecte en Bluetooth à votre montre Polar pour y déposer la séance du jour, avec ses phases et ses zones cardiaques. La connexion ne sert qu'à cet envoi et aucune donnée n'est lue sur la montre.</string>
<key>NSCameraUsageDescription</key>
<string>Coach Hypnotruck utilise l'appareil photo pour scanner les codes-barres des aliments, photographier vos assiettes et photographier les étiquettes nutritionnelles. Ces images servent à identifier l'aliment et à estimer ses valeurs nutritionnelles, puis à alimenter votre journal alimentaire.</string>
<key>NSHealthShareUsageDescription</key>

View File

@@ -36,6 +36,11 @@ class MainViewController: CAPBridgeViewController {
// l'app watchOS, et relaie les coches faites sur la montre vers
// /api/routine/day (cf. CoachRoutineBridge.swift).
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
// Écriture d'un objectif sur la Polar Vantage V3 en Bluetooth (PFTP).
// Ne transporte que des octets produits par le serveur, et refuse toute
// requête malformée avant d'ouvrir la radio le firmware plante au lieu
// de refuser (cf. CoachPolarBLE.swift et PolarPftpStep.swift).
bridge?.registerPluginInstance(CoachPolarBLEPlugin())
}
/// Injecte le cookie d'auth dans le magasin de la WKWebView.

View File

@@ -0,0 +1,117 @@
// 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)
case watchNotFound(Double)
case psftpUnavailable
public var description: String {
switch self {
case .malformed(let why): return why
case .watchNotFound(let seconds):
return "aucune montre Polar exposant PsFTP trouvée en \(Int(seconds)) s. "
+ "Vérifier que la montre est allumée, à portée, et que l'app Polar "
+ "Flow n'est pas en train de synchroniser — le canal BLE ne se "
+ "partage pas."
case .psftpUnavailable:
return "session ouverte mais le service PsFTP (FEEE) n'a pas répondu"
}
}
}

View File

@@ -0,0 +1 @@
../../../ios/App/App/PolarPftpStep.swift

View File

@@ -0,0 +1,120 @@
// 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"))
}
}