From 7d68605e839652480b41fef7afc4d782fdbc050f Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Tue, 1 Sep 2026 08:25:26 +0000 Subject: [PATCH] =?UTF-8?q?=C2=AB=20errorcode=20104=20=C2=BB=20n'=C3=A9tai?= =?UTF-8?q?t=20pas=20un=20refus=20:=20mkdir=20sur=20un=20dossier=20d=C3=A9?= =?UTF-8?q?j=C3=A0=20l=C3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/App/App/CoachPolarBLE.swift | 41 ++++++++++++++++--- ios/App/App/PolarPftpStep.swift | 32 +++++++++++++++ .../CoachModelTests/PolarPftpStepTests.swift | 29 +++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/ios/App/App/CoachPolarBLE.swift b/ios/App/App/CoachPolarBLE.swift index 59d63b6..39b97ad 100644 --- a/ios/App/App/CoachPolarBLE.swift +++ b/ios/App/App/CoachPolarBLE.swift @@ -125,8 +125,13 @@ public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin { #if canImport(PolarBleSdk) Task { do { - let journal = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout) - call.resolve(["sent": true, "dryRun": false, "log": journal]) + let issue = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout) + 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 { call.reject("\(error)") } @@ -182,7 +187,12 @@ final class PolarPsFtpWriter { private var publisherAParle = false 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 // dit. `CBDeviceListenerImpl.search()` commence par // `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état @@ -270,15 +280,34 @@ final class PolarPsFtpWriter { defer { listener.closeSessionDirect(session) } var journal: [String] = [] + var dossierPreexistant = false 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) + do { + try await put(client, etape) + 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, diff --git a/ios/App/App/PolarPftpStep.swift b/ios/App/App/PolarPftpStep.swift index cc986b0..717ef1a 100644 --- a/ios/App/App/PolarPftpStep.swift +++ b/ios/App/App/PolarPftpStep.swift @@ -25,6 +25,38 @@ 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 diff --git a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift index adc00ca..8ed4ce8 100644 --- a/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift +++ b/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift @@ -290,3 +290,32 @@ extension PolarPftpErrorTests { "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)) + } +}