« errorcode 104 » n'était pas un refus : mkdir sur un dossier déjà là

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>
This commit is contained in:
Sylvain Bettinelli
2026-09-01 08:25:26 +00:00
parent 474afce348
commit 7d68605e83
3 changed files with 96 additions and 6 deletions

View File

@@ -125,8 +125,13 @@ public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin {
#if canImport(PolarBleSdk) #if canImport(PolarBleSdk)
Task { Task {
do { do {
let journal = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout) let issue = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout)
call.resolve(["sent": true, "dryRun": false, "log": journal]) call.resolve(["sent": true, "dryRun": false,
"log": issue.journal,
// L'UI s'en sert pour prévenir d'un renvoi : la
// montre garde son index en cache, et l'objectif
// réécrit n'apparaît qu'après un redémarrage.
"alreadyExisted": issue.dossierPreexistant])
} catch { } catch {
call.reject("\(error)") call.reject("\(error)")
} }
@@ -182,7 +187,12 @@ final class PolarPsFtpWriter {
private var publisherAParle = false private var publisherAParle = false
private var derniereErreurSdk: String? private var derniereErreurSdk: String?
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] { /// Écrit les étapes et rend le journal, plus un drapeau : le dossier de
/// destination existait-il déjà ? Ce drapeau remonte jusqu'à l'UI, qui doit
/// avertir un objectif réécrit n'apparaît qu'après redémarrage de la
/// montre.
func ecrire(_ etapes: [PftpStep], timeout: Double) async throws
-> (journal: [String], dossierPreexistant: Bool) {
// PRÉAMBULE INDISPENSABLE sans lui, rien ne se passe et rien ne le // PRÉAMBULE INDISPENSABLE sans lui, rien ne se passe et rien ne le
// dit. `CBDeviceListenerImpl.search()` commence par // dit. `CBDeviceListenerImpl.search()` commence par
// `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état // `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état
@@ -270,15 +280,34 @@ final class PolarPsFtpWriter {
defer { listener.closeSessionDirect(session) } defer { listener.closeSessionDirect(session) }
var journal: [String] = [] var journal: [String] = []
var dossierPreexistant = false
for etape in etapes { for etape in etapes {
// Revalidé juste avant l'émission : entre la validation d'entrée et // Revalidé juste avant l'émission : entre la validation d'entrée et
// ce point, rien ne doit avoir introduit un chemin de dossier avec // ce point, rien ne doit avoir introduit un chemin de dossier avec
// du contenu. // du contenu.
try etape.validate() try etape.validate()
do {
try await put(client, etape) try await put(client, etape)
journal.append("" + etape.describe) journal.append("" + etape.describe)
} catch BlePsFtpException.responseError(let code)
where etape.isDirectory && PftpCode.mkdirEstSatisfait(par: code) {
// `mkdir -p` : le dossier est là, c'est tout ce qu'on demandait.
//
// Rapporté par Sylvain le 01/09/2026 sous la forme
// « errorcode 104 » ce qui donnait à croire à un refus de la
// montre alors que la session BLE s'était parfaitement ouverte.
// Le commentaire de `parseSteps` disait « aucun des deux
// n'existe d'avance pour une date neuve » : c'est vrai d'une
// date NEUVE, et faux dès le second envoi vers la même date.
//
// Le renvoi est signalé plus haut, jamais avalé en silence :
// réécrire un objectif déjà présent n'est visible qu'après un
// redémarrage de la montre (index en cache, constaté le 31/08).
dossierPreexistant = true
journal.append("" + etape.describe + " — existait déjà")
} }
return journal }
return (journal, dossierPreexistant)
} }
private func trouverClient(_ listener: CBDeviceListenerImpl, private func trouverClient(_ listener: CBDeviceListenerImpl,

View File

@@ -25,6 +25,38 @@
import Foundation 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. /// Un PUT PFTP : un chemin, un contenu. Un contenu vide crée un dossier.
public struct PftpStep: Equatable { public struct PftpStep: Equatable {
public let path: String public let path: String

View File

@@ -290,3 +290,32 @@ extension PolarPftpErrorTests {
"l'hypothèse du lien exclusif doit être énoncée") "l'hypothèse du lien exclusif doit être énoncée")
} }
} }
// MARK: - Codes d'erreur PFTP (01/09/2026)
/// « errorcode 104 » rapporté par Sylvain sur un second envoi vers la même
/// date. Ce n'était pas un refus de la montre : la session BLE s'était ouverte,
/// et c'est notre séquence de `mkdir` qui n'était pas idempotente.
final class PftpCodeTests: XCTestCase {
func test104EstBienDirectoryExists() {
XCTAssertEqual(PftpCode.directoryExists, 104)
}
func testUnMkdirSurDossierExistantEstSatisfait() {
XCTAssertTrue(PftpCode.mkdirEstSatisfait(par: 104))
}
func testLesAutresCodesRestentDesEchecs() {
// 103 NO_SUCH_FILE_OR_DIRECTORY : le parent manque, l'ordre des mkdir
// est en cause surtout pas à avaler.
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 103))
// 105 FILE_EXISTS : l'objectif est déjà écrit. L'appelant DOIT le
// savoir le remplacer n'est visible qu'après redémarrage de la montre.
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 105))
// 106 OPERATION_NOT_PERMITTED, 108 TIMEOUT : de vrais refus.
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 106))
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 108))
XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 0))
}
}